diff --git a/docs/changelog/7.x.x.txt b/docs/changelog/7.x.x.txt index cca14f8c9..ef29a5a85 100644 --- a/docs/changelog/7.x.x.txt +++ b/docs/changelog/7.x.x.txt @@ -21,6 +21,7 @@ transactions. (Martin Kamerbeek / Oqapi ) - Added better Survey Expression Engine validation warnings - added #9203: Survey Visualization + - Added: United States Postal Service Shipping Driver. 7.7.5 - Adding StoryManager. diff --git a/docs/upgrades/upgrade_7.7.5-7.7.6.pl b/docs/upgrades/upgrade_7.7.5-7.7.6.pl index 598ba5f2b..51f18d874 100644 --- a/docs/upgrades/upgrade_7.7.5-7.7.6.pl +++ b/docs/upgrades/upgrade_7.7.5-7.7.6.pl @@ -33,6 +33,7 @@ addTemplateAttachmentsTable($session); revertUsePacked( $session ); fixDefaultPostReceived($session); addEuVatDbColumns( $session ); +addShippingDrivers( $session ); addTransactionTaxColumns( $session ); finish($session); @@ -91,6 +92,15 @@ EOSQL print "DONE!\n" unless $quiet; } +#---------------------------------------------------------------------------- +# Describe what our function does +sub addShippingDrivers { + my $session = shift; + print "\tAdding columns for improved VAT number checking..." unless $quiet; + $session->config->addToArray('shippingDrivers', 'WebGUI::Shop::ShipDriver::USPS'); + print "DONE!\n" unless $quiet; +} + #---------------------------------------------------------------------------- sub addEuVatDbColumns { my $session = shift; diff --git a/etc/WebGUI.conf.original b/etc/WebGUI.conf.original index 97c131109..814a864ac 100644 --- a/etc/WebGUI.conf.original +++ b/etc/WebGUI.conf.original @@ -183,7 +183,8 @@ # available for configuration on the site. "shippingDrivers" : [ - "WebGUI::Shop::ShipDriver::FlatRate" + "WebGUI::Shop::ShipDriver::FlatRate", + "WebGUI::Shop::ShipDriver::USPS" ], # Specify the list of template parsers available in the system. diff --git a/lib/WebGUI/Exception/Shop.pm b/lib/WebGUI/Exception/Shop.pm index 5fb899dbf..422f55fe1 100644 --- a/lib/WebGUI/Exception/Shop.pm +++ b/lib/WebGUI/Exception/Shop.pm @@ -20,7 +20,12 @@ use Exception::Class ( 'WebGUI::Error::Shop::MaxOfItemInCartReached' => { description => "Some items restrict how many you can put into your cart.", - }, + }, + + 'WebGUI::Error::Shop::RemoteShippingRate' => { + description => "Errors during the remote rate lookups.", + }, + ); diff --git a/lib/WebGUI/Shop/Cart.pm b/lib/WebGUI/Shop/Cart.pm index 1187ed80a..38c200ae0 100644 --- a/lib/WebGUI/Shop/Cart.pm +++ b/lib/WebGUI/Shop/Cart.pm @@ -510,9 +510,14 @@ sub readyForCheckout { return 0 if ($self->hasMixedItems); # Check minimum cart checkout requirement + my $total = eval { $self->calculateTotal }; + if (my $e = WebGUI::Error->caught) { + $error{id $self} = $e->error; + return 0; + } my $requiredAmount = $self->session->setting->get( 'shopCartCheckoutMinimum' ); if ( $requiredAmount > 0 ) { - return 0 if $self->calculateTotal < $requiredAmount; + return 0 if $total < $requiredAmount; } # All checks passed so return true diff --git a/lib/WebGUI/Shop/Ship.pm b/lib/WebGUI/Shop/Ship.pm index bc7e82fe9..7a1e4627c 100644 --- a/lib/WebGUI/Shop/Ship.pm +++ b/lib/WebGUI/Shop/Ship.pm @@ -97,12 +97,17 @@ sub getOptions { WebGUI::Error::InvalidParam->throw(error => q{Need a cart.}) unless defined $cart and $cart->isa("WebGUI::Shop::Cart"); my $session = $cart->session; my %options = (); - foreach my $shipper (@{$self->getShippers()}) { - next unless $shipper->get('enabled'); + SHIPPER: foreach my $shipper (@{$self->getShippers()}) { + next SHIPPER unless $shipper->get('enabled'); + my $price = eval { $shipper->calculate($cart) }; + if (my $e = WebGUI::Error->caught()) { + $self->session->log->warn($e->error); + next SHIPPER; + } $options{$shipper->getId} = { label => $shipper->get("label"), - price => $shipper->calculate($cart), - }; + price => $price, + }; } return \%options; } diff --git a/lib/WebGUI/Shop/ShipDriver.pm b/lib/WebGUI/Shop/ShipDriver.pm index 30f385d47..7fe08e75a 100644 --- a/lib/WebGUI/Shop/ShipDriver.pm +++ b/lib/WebGUI/Shop/ShipDriver.pm @@ -32,8 +32,8 @@ These subroutines are available from this package: =cut readonly session => my %session; -private options => my %options; -private shipperId => my %shipperId; +private options => my %options; +private shipperId => my %shipperId; #------------------------------------------------------------------- @@ -88,6 +88,9 @@ This subroutine returns an arrayref of hashrefs, used to validate data put into the object by the user, and to automatically generate the edit form to show the user. +The optional hash key noFormProcess may be added to any field definition. +This will prevent that field from being processed by processPropertiesFromFormPost. + =cut sub definition { @@ -261,17 +264,20 @@ Updates ship driver with data from Form. =cut sub processPropertiesFromFormPost { - my $self = shift; + my $self = shift; + my $session = $self->session; + my $form = $session->form; my %properties; - my $fullDefinition = $self->definition($self->session); + my $fullDefinition = $self->definition($session); foreach my $definition (@{$fullDefinition}) { - foreach my $property (keys %{$definition->{properties}}) { - $properties{$property} = $self->session->form->process( - $property, - $definition->{properties}{$property}{fieldType}, - $definition->{properties}{$property}{defaultValue} - ); - } + PROPERTY: foreach my $property (keys %{$definition->{properties}}) { + next PROPERTY if $definition->{properties}{$property}->{noFormProcess}; + $properties{$property} = $form->process( + $property, + $definition->{properties}{$property}{fieldType}, + $definition->{properties}{$property}{defaultValue} + ); + } } $properties{title} = $fullDefinition->[0]{name} if ($properties{title} eq "" || lc($properties{title}) eq "untitled"); $self->update(\%properties); diff --git a/lib/WebGUI/Shop/ShipDriver/USPS.pm b/lib/WebGUI/Shop/ShipDriver/USPS.pm new file mode 100644 index 000000000..2cc0f0eef --- /dev/null +++ b/lib/WebGUI/Shop/ShipDriver/USPS.pm @@ -0,0 +1,347 @@ +package WebGUI::Shop::ShipDriver::USPS; + +use strict; +use base qw/WebGUI::Shop::ShipDriver/; +use WebGUI::Exception; +use XML::Simple; +use LWP; +use Tie::IxHash; + +=head1 NAME + +Package WebGUI::Shop::ShipDriver::USPS + +=head1 DESCRIPTION + +Shipping driver for the United States Postal Service. + +=head1 SYNOPSIS + +=head1 METHODS + +See the master class, WebGUI::Shop::ShipDriver for information about +base methods. These methods are customized in this class: + +=cut + +#------------------------------------------------------------------- + +=head2 buildXML ( $cart, @packages ) + +Returns XML for submitting to the US Postal Service servers + +=head3 $cart + +A WebGUI::Shop::Cart object. This allows us access to the user's +address book + +=head3 @packages + +An array of array references. Each array element is 1 set of items. The +quantity of items will vary in each set. If the quantity of an item +is more than 1, then we will check for shipping 1 item, and multiple the +result by the quantity, rather than doing several identical checks. + +=cut + +sub buildXML { + my ($self, $cart, @packages) = @_; + tie my %xmlHash, 'Tie::IxHash'; + %xmlHash = ( RateV3Request => {}, ); + my $xmlTop = $xmlHash{RateV3Request}; + $xmlTop->{USERID} = $self->get('userId'); + $xmlTop->{Package} = []; + ##Do a request for each package. + my $packageIndex; + PACKAGE: for(my $packageIndex = 0; $packageIndex < scalar @packages; $packageIndex++) { + my $package = $packages[$packageIndex]; + next PACKAGE unless scalar @{ $package }; + tie my %packageData, 'Tie::IxHash'; + my $weight = 0; + foreach my $item (@{ $package }) { + my $sku = $item->getSku; + my $itemWeight = $sku->getWeight(); + ##Items that ship separately with a quantity > 1 are rate estimated as 1 item and then the + ##shipping cost is multiplied by the quantity. + if (! $sku->shipsSeparately ) { + $itemWeight *= $item->get('quantity'); + } + $weight += $itemWeight; + } + my $pounds = int($weight); + my $ounces = int(16 * ($weight - $pounds)); + my $destination = $package->[0]->getShippingAddress; + my $destZipCode = $destination->get('code'); + $packageData{ID} = $packageIndex; + $packageData{Service} = [ $self->get('shipType') ]; + $packageData{ZipOrigination} = [ $self->get('sourceZip') ]; + $packageData{ZipDestination} = [ $destZipCode ]; + $packageData{Pounds} = [ $pounds ]; + $packageData{Ounces} = [ $ounces ]; + if ($self->get('shipType') eq 'PRIORITY') { + $packageData{Container} = [ 'FLAT RATE BOX' ]; + } + $packageData{Size} = [ 'REGULAR' ]; + $packageData{Machinable} = [ 'true' ]; + push @{ $xmlTop->{Package} }, \%packageData; + } + my $xml = XMLout(\%xmlHash, + KeepRoot => 1, + NoSort => 1, + NoIndent => 1, + KeyAttr => { + Package => 'ID', + }, + SuppressEmpty => 0, + ); + return $xml; +} + + +#------------------------------------------------------------------- + +=head2 calculate ( $cart ) + +Returns a shipping price. + +=head3 $cart + +A WebGUI::Shop::Cart object. The contents of the cart are analyzed to calculate +the shipping costs. If no items in the cart require shipping, then no shipping +costs are assessed. + +=cut + +sub calculate { + my ($self, $cart) = @_; + if (! $self->get('sourceZip')) { + WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a source zipcode.}); + } + if (! $self->get('userId')) { + WebGUI::Error::InvalidParam->throw(error => q{Driver configured without a USPS userId.}); + } + my $cost = 0; + ##Sort the items into shippable bundles. + my @shippableUnits = $self->_getShippableUnits($cart); + my $packageCount = scalar @shippableUnits; + if ($packageCount > 25) { + WebGUI::Error::InvalidParam->throw(error => q{Cannot do USPS lookups for more than 25 items.}); + } + my $anyShippable = $packageCount > 0 ? 1 : 0; + return $cost unless $anyShippable; + #$cost = scalar @shippableUnits * $self->get('flatFee'); + ##Build XML ($cart, @shippableUnits) + my $xml = $self->buildXML($cart, @shippableUnits); + ##Do request ($xml) + my $response = $self->_doXmlRequest($xml); + ##Error handling + if (! $response->is_success) { + WebGUI::Error::RemoteShippingRate->throw(error => 'Problem connecting to USPS Web Tools: '. $response->status_line); + } + my $returnedXML = $response->content; + my $xmlData = XMLin($returnedXML, ForceArray => [qw/Package/]); + if (exists $xmlData->{Error}) { + WebGUI::Error::RemoteShippingRate->throw(error => 'Problem with USPS Web Tools XML: '. $xmlData->{Description}); + } + ##Summarize costs from returned data + $cost = $self->_calculateFromXML($xmlData, @shippableUnits); + return $cost; +} + +#------------------------------------------------------------------- + +=head2 _calculateFromXML ( $xmlData, @shippableUnits ) + +Takes data from the USPS and returns the calculated shipping price. + +=head3 $xmlData + +Processed XML data from an XML rate request, processed in perl data structure. The data is expected to +have this structure: + + { + Package => [ + { + ID => 0, + Postage => { + Rate => some_number + } + }, + ] + } + +=head3 @shippableUnits + +The set of shippable units, which are required to do quantity lookups. + +=cut + +sub _calculateFromXML { + my ($self, $xmlData, @shippableUnits) = @_; + my $cost = 0; + foreach my $package (@{ $xmlData->{Package} }) { + my $id = $package->{ID}; + my $rate = $package->{Postage}->{Rate}; + ##Error check for invalid index + if ($id < 0 || $id > $#shippableUnits) { + WebGUI::Error::RemoteShippingRate->throw(error => "Illegal package index returned by USPS: $id"); + } + my $unit = $shippableUnits[$id]; + if ($unit->[0]->getSku->shipsSeparately) { + ##This is a single item due to ships separately. Since in reality there will be + ## N things being shipped, multiply the rate by the quantity. + $cost += $rate * $unit->[0]->get('quantity'); + } + else { + ##This is a loose bundle of items, all shipped together + $cost += $rate; + } + } + return $cost; +} + +#------------------------------------------------------------------- + +=head2 definition ( $session ) + +This subroutine returns an arrayref of hashrefs, used to validate data put into +the object by the user, and to automatically generate the edit form to show +the user. + +=cut + +sub definition { + my $class = shift; + my $session = shift; + WebGUI::Error::InvalidParam->throw(error => q{Must provide a session variable}) + unless ref $session eq 'WebGUI::Session'; + my $definition = shift || []; + my $i18n = WebGUI::International->new($session, 'ShipDriver_USPS'); + tie my %shippingTypes, 'Tie::IxHash'; + ##Note, these keys are required XML keywords in the USPS XML API. + $shippingTypes{'PRIORITY'} = $i18n->get('priority'); + $shippingTypes{'EXPRESS' } = $i18n->get('express'); + $shippingTypes{'PARCEL' } = $i18n->get('parcel post'); + tie my %fields, 'Tie::IxHash'; + %fields = ( + instructions => { + fieldType => 'readOnly', + label => $i18n->get('instructions'), + defaultValue => $i18n->get('usps instructions'), + noFormProcess => 1, + }, + userId => { + fieldType => 'text', + label => $i18n->get('userid'), + hoverHelp => $i18n->get('userid help'), + defaultValue => '', + }, + password => { + fieldType => 'password', + label => $i18n->get('password'), + hoverHelp => $i18n->get('password help'), + defaultValue => '', + }, + sourceZip => { + fieldType => 'zipcode', + label => $i18n->get('source zipcode'), + hoverHelp => $i18n->get('source zipcode help'), + defaultValue => '', + }, + shipType => { + fieldType => 'selectBox', + label => $i18n->get('ship type'), + hoverHelp => $i18n->get('ship type help'), + options => \%shippingTypes, + defaultValue => 'PARCEL', + }, +##Note, if a flat fee is added to this driver, then according to the license +##terms the website must display a note to the user (shop customer) that additional +##fees have been added. +# flatFee => { +# fieldType => 'float', +# label => $i18n->get('flatFee'), +# hoverHelp => $i18n->get('flatFee help'), +# defaultValue => 0, +# }, + ); + my %properties = ( + name => 'U.S. Postal Service', + properties => \%fields, + ); + push @{ $definition }, \%properties; + return $class->SUPER::definition($session, $definition); +} + +#------------------------------------------------------------------- + +=head2 _doXmlRequest ( $xml ) + +Contact the USPS website and submit the XML for a shipping rate lookup. +Returns a LWP::UserAgent response object. + +=head3 $xml + +XML to send. It has some very high standards, including XML components in +the right order and sets of allowed tags. + +=cut + +sub _doXmlRequest { + my ($self, $xml) = @_; + my $userAgent = LWP::UserAgent->new; + $userAgent->env_proxy; + $userAgent->agent('WebGUI'); + my $url = 'http://production.shippingapis.com/ShippingAPI.dll?API=RateV3&XML='; + $url .= $xml; + my $request = HTTP::Request->new(GET => $url); + my $response = $userAgent->request($request); + return $response; +} + +#------------------------------------------------------------------- + +=head2 _getShippableUnits ( $cart ) + +This is a private method. + +Sorts items into the cart by how they must be shipped, together, separate, +etc. Returns an array of array references of cart items grouped by +whether or not they ship separately, and then sorted by destination +zip code. + +If an item in the cart must be shipped separately, but has a quantity greater +than 1, then for the purposes of looking up shipping costs it is returned +as 1 bundle, since the total cost can now be calculated by multiplying the +quantity together with the cost for a single unit. + +For an empty cart (which shouldn't ever happen), it would return an empty array. + +=head3 $cart + +A WebGUI::Shop::Cart object. It provides access to the items in the cart +that must be sorted. + +=cut + +sub _getShippableUnits { + my ($self, $cart) = @_; + my @shippableUnits = (); + ##Loose units are sorted by zip code. + my %looseUnits = (); + ITEM: foreach my $item (@{$cart->getItems}) { + my $sku = $item->getSku; + next ITEM unless $sku->isShippingRequired; + if ($sku->shipsSeparately) { + push @shippableUnits, [ $item ]; + } + else { + my $zip = $item->getShippingAddress->get('code'); + push @{ $looseUnits{$zip} }, $item; + } + } + push @shippableUnits, values %looseUnits; + return @shippableUnits; +} + +1; diff --git a/lib/WebGUI/i18n/English/ShipDriver_USPS.pm b/lib/WebGUI/i18n/English/ShipDriver_USPS.pm new file mode 100644 index 000000000..21550a51a --- /dev/null +++ b/lib/WebGUI/i18n/English/ShipDriver_USPS.pm @@ -0,0 +1,93 @@ +package WebGUI::i18n::English::ShipDriver_USPS; + +use strict; + +our $I18N = { + + 'userid' => { + message => q|USPS Web Tools Username|, + lastUpdated => 1203569535, + context => q|Label in the ShipDriver edit form.|, + }, + + 'userid help' => { + message => q|You can get a Web Tools Username by first registering with the USPS.|, + lastUpdated => 1203569511, + }, + + 'password' => { + message => q|USPS Web Tools Password (optional)|, + lastUpdated => 1203569535, + context => q|Label in the ShipDriver edit form.|, + }, + + 'password help' => { + message => q|You will recieve a password along with your username when you register.|, + lastUpdated => 1203569511, + }, + + 'instructions' => { + message => q|Registration Instructions|, + lastUpdated => 1203569535, + context => q|Label in the ShipDriver edit form.|, + }, + + 'usps instructions' => { + lastUpdated => 1241028258, + message => q|
In order to use the USPS Shipping Driver, you must first register with the United States Postal Service as a USPS Web Tools User. Fill out the form, submit it, and within a few days the USPS will send you a username and password to use this service. Enter your username and password in the form fields below.
This driver supports three kinds of shipping with one preset size for each kind. Package sizes, and shipping services outside of those choices, are currently not supported.
For the purpose of calculating weight, the weight property of a Product is considered to be in pounds.|,
+ },
+
+ 'ship type' => {
+ message => q|Shipping type|,
+ lastUpdated => 1203569535,
+ context => q|Label in the ShipDriver edit form.|,
+ },
+
+ 'ship type help' => {
+ message => q|Select one from the list of options. If you wish to provide multiple types of shipping, create one additional shipping driver instance for each option.|,
+ lastUpdated => 1203569511,
+ },
+
+ 'source zipcode' => {
+ message => q|Shipping Zipcode|,
+ lastUpdated => 1203569535,
+ context => q|Label in the ShipDriver edit form.|,
+ },
+
+ 'source zipcode help' => {
+ message => q|The zipcode of the location you will be shipping from.|,
+ lastUpdated => 1203569511,
+ },
+
+ 'flatFee' => {
+ message => q|Flat Fee|,
+ lastUpdated => 1241214572,
+ context => q|A fixed amount of money added to a purchase for shipping.|,
+ },
+
+ 'flatFee help' => {
+ message => q|A fixed amount of money added to a purchase for shipping, covering shipping materials and handling.|,
+ lastUpdated => 1241214575,
+ },
+
+ 'priority' => {
+ message => q|Priority, Flat Rate Box|,
+ lastUpdated => 1203569511,
+ context => q|Label for a type of shipping from the USPS.|,
+ },
+
+ 'express' => {
+ message => q|Express, Regular size|,
+ lastUpdated => 1203569511,
+ context => q|Label for a type of shipping from the USPS.|,
+ },
+
+ 'parcel post' => {
+ message => q|Parcel Post, Regular size|,
+ lastUpdated => 1242166045,
+ context => q|Label for a type of shipping from the USPS.|,
+ },
+
+};
+
+1;
diff --git a/t/Shop/ShipDriver/USPS.t b/t/Shop/ShipDriver/USPS.t
new file mode 100644
index 000000000..647be9510
--- /dev/null
+++ b/t/Shop/ShipDriver/USPS.t
@@ -0,0 +1,718 @@
+# vim:syntax=perl
+#-------------------------------------------------------------------
+# WebGUI is Copyright 2001-2009 Plain Black Corporation.
+#-------------------------------------------------------------------
+# Please read the legal notices (docs/legal.txt) and the license
+# (docs/license.txt) that came with this distribution before using
+# this software.
+#------------------------------------------------------------------
+# http://www.plainblack.com info@plainblack.com
+#------------------------------------------------------------------
+
+# Write a little about what this script tests.
+#
+#
+
+use FindBin;
+use strict;
+use lib "$FindBin::Bin/../../lib";
+use Test::More;
+use Test::Deep;
+use XML::Simple;
+use Data::Dumper;
+
+use WebGUI::Test; # Must use this before any other WebGUI modules
+use WebGUI::Session;
+
+#----------------------------------------------------------------------------
+# Init
+my $session = WebGUI::Test->session;
+my $user = WebGUI::User->create($session);
+WebGUI::Test->usersToDelete($user);
+$session->user({user => $user});
+
+#----------------------------------------------------------------------------
+# Tests
+
+my $tests = 41;
+plan tests => 1 + $tests;
+
+#----------------------------------------------------------------------------
+# put your tests here
+
+my $loaded = use_ok('WebGUI::Shop::ShipDriver::USPS');
+
+my $storage;
+my ($driver, $cart);
+my $versionTag = WebGUI::VersionTag->getWorking($session);
+
+my $home = WebGUI::Asset->getDefault($session);
+
+my $rockHammer = $home->addChild({
+ className => 'WebGUI::Asset::Sku::Product',
+ isShippingRequired => 1, title => 'Rock Hammers',
+ shipsSeparately => 0,
+});
+
+my $smallHammer = $rockHammer->setCollateral('variantsJSON', 'variantId', 'new',
+ {
+ shortdesc => 'Small rock hammer', price => 7.50,
+ varSku => 'small-hammer', weight => 1.5,
+ quantity => 9999,
+ }
+);
+
+my $bigHammer = $rockHammer->setCollateral('variantsJSON', 'variantId', 'new',
+ {
+ shortdesc => 'Big rock hammer', price => 19.99,
+ varSku => 'big-hammer', weight => 12,
+ quantity => 9999,
+ }
+);
+
+my $bible = $home->addChild({
+ className => 'WebGUI::Asset::Sku::Product',
+ isShippingRequired => 1, title => 'Bibles, individuall wrapped and shipped',
+ shipsSeparately => 1,
+});
+
+my $kjvBible = $bible->setCollateral('variantsJSON', 'variantId', 'new',
+ {
+ shortdesc => 'King James Bible', price => 17.50,
+ varSku => 'kjv-bible', weight => 2.5,
+ quantity => 99999,
+ }
+);
+
+my $nivBible = $bible->setCollateral('variantsJSON', 'variantId', 'new',
+ {
+ shortdesc => 'NIV Bible', price => 22.50,
+ varSku => 'niv-bible', weight => 2.0,
+ quantity => 999999,
+ }
+);
+
+$versionTag->commit;
+
+SKIP: {
+
+skip 'Unable to load module WebGUI::Shop::ShipDriver::USPS', $tests unless $loaded;
+
+#######################################################################
+#
+# definition
+#
+#######################################################################
+
+my $definition;
+my $e; ##Exception variable, used throughout the file
+
+eval { $definition = WebGUI::Shop::ShipDriver::USPS->definition(); };
+$e = Exception::Class->caught();
+isa_ok($e, 'WebGUI::Error::InvalidParam', 'definition takes an exception to not giving it a session variable');
+cmp_deeply(
+ $e,
+ methods(
+ error => 'Must provide a session variable',
+ ),
+ '... checking error message',
+);
+
+
+isa_ok(
+ $definition = WebGUI::Shop::ShipDriver::USPS->definition($session),
+ 'ARRAY'
+);
+
+
+#######################################################################
+#
+# create
+#
+#######################################################################
+
+my $options = {
+ label => 'USPS Driver',
+ enabled => 1,
+ };
+
+$driver = WebGUI::Shop::ShipDriver::USPS->create($session, $options);
+
+isa_ok($driver, 'WebGUI::Shop::ShipDriver::USPS');
+isa_ok($driver, 'WebGUI::Shop::ShipDriver');
+
+#######################################################################
+#
+# getName
+#
+#######################################################################
+
+is (WebGUI::Shop::ShipDriver::USPS->getName($session), 'U.S. Postal Service', 'getName returns the human readable name of this driver');
+
+#######################################################################
+#
+# delete
+#
+#######################################################################
+
+my $driverId = $driver->getId;
+$driver->delete;
+
+my $count = $session->db->quickScalar('select count(*) from shipper where shipperId=?',[$driverId]);
+is($count, 0, 'delete deleted the object');
+
+undef $driver;
+
+#######################################################################
+#
+# calculate, and private methods.
+#
+#######################################################################
+
+$driver = WebGUI::Shop::ShipDriver::USPS->create($session, {
+ label => 'Shipping from Shawshank',
+ enabled => 1,
+ shipType => 'PARCEL',
+});
+
+eval { $driver->calculate() };
+$e = Exception::Class->caught();
+isa_ok($e, 'WebGUI::Error::InvalidParam', 'calculate throws an exception when no zipcode has been set');
+cmp_deeply(
+ $e,
+ methods(
+ error => 'Driver configured without a source zipcode.',
+ ),
+ '... checking error message',
+);
+
+my $properties = $driver->get();
+$properties->{sourceZip} = '97123';
+$driver->update($properties);
+
+eval { $driver->calculate() };
+$e = Exception::Class->caught();
+isa_ok($e, 'WebGUI::Error::InvalidParam', 'calculate throws an exception when no userId');
+cmp_deeply(
+ $e,
+ methods(
+ error => 'Driver configured without a USPS userId.',
+ ),
+ '... checking error message',
+);
+
+$cart = WebGUI::Shop::Cart->newBySession($session);
+my $addressBook = $cart->getAddressBook;
+my $workAddress = $addressBook->addAddress({
+ label => 'work',
+ organization => 'Plain Black Corporation',
+ address1 => '1360 Regent St. #145',
+ city => 'Madison', state => 'WI', code => '53715',
+ country => 'USA',
+});
+my $wucAddress = $addressBook->addAddress({
+ label => 'wuc',
+ organization => 'Madison Concourse Hotel',
+ address1 => '1 W Dayton St',
+ city => 'Madison', state => 'WI', code => '53703',
+ country => 'USA',
+});
+$cart->update({shippingAddressId => $workAddress->getId});
+
+cmp_deeply(
+ [$driver->_getShippableUnits($cart)],
+ [(), ],
+ '_getShippableUnits: empty cart'
+);
+
+$rockHammer->addToCart($rockHammer->getCollateral('variantsJSON', 'variantId', $smallHammer));
+cmp_deeply(
+ [$driver->_getShippableUnits($cart)],
+ [[ ignore() ], ],
+ '_getShippableUnits: one loose item in the cart'
+);
+
+$rockHammer->addToCart($rockHammer->getCollateral('variantsJSON', 'variantId', $bigHammer));
+cmp_deeply(
+ [$driver->_getShippableUnits($cart)],
+ [[ ignore(), ignore() ], ],
+ '_getShippableUnits: two loose items in the cart'
+);
+
+$bible->addToCart($bible->getCollateral('variantsJSON', 'variantId', $kjvBible));
+cmp_bag(
+ [$driver->_getShippableUnits($cart)],
+ [[ ignore(), ignore() ], [ ignore(), ], ],
+ '_getShippableUnits: two loose items, and 1 ships separately item in the cart'
+);
+
+my $bibleItem = $bible->addToCart($bible->getCollateral('variantsJSON', 'variantId', $nivBible));
+$bibleItem->setQuantity(5);
+cmp_bag(
+ [$driver->_getShippableUnits($cart)],
+ [[ ignore(), ignore() ], [ ignore() ], [ ignore() ], ],
+ '_getShippableUnits: two loose items, and 2 ships separately item in the cart, regarless of quantity for the new item'
+);
+
+my $rockHammer2 = $bible->addToCart($rockHammer->getCollateral('variantsJSON', 'variantId', $smallHammer));
+$rockHammer2->update({shippingAddressId => $wucAddress->getId});
+cmp_bag(
+ [$driver->_getShippableUnits($cart)],
+ [[ ignore(), ignore() ], [ ignore() ], [ ignore() ], [ ignore() ], ],
+ '_getShippableUnits: two loose items, and 2 ships separately item in the cart, and another loose item sorted by zipcode'
+);
+
+$cart->empty;
+$bible->addToCart($bible->getCollateral('variantsJSON', 'variantId', $nivBible));
+cmp_deeply(
+ [$driver->_getShippableUnits($cart)],
+ [ [ ignore() ], ],
+ '_getShippableUnits: only 1 ships separately item in the cart'
+);
+$cart->empty;
+
+my $userId = $session->config->get('testing/USPS_userId');
+my $hasRealUserId = 1;
+##If there isn't a userId, set a fake one for XML testing.
+if (! $userId) {
+ $hasRealUserId = 0;
+ $userId = "blahBlahBlah";
+}
+$properties = $driver->get();
+$properties->{userId} = $userId;
+$properties->{sourceZip} = '97123';
+$driver->update($properties);
+
+$rockHammer->addToCart($rockHammer->getCollateral('variantsJSON', 'variantId', $smallHammer));
+my @shippableUnits = $driver->_getShippableUnits($cart);
+
+my $xml = $driver->buildXML($cart, @shippableUnits);
+like($xml, qr/