bcarpenter-active_shipping 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. data/CHANGELOG +23 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.markdown +173 -0
  4. data/Rakefile +52 -0
  5. data/VERSION +1 -0
  6. data/lib/active_shipping.rb +50 -0
  7. data/lib/active_shipping/lib/connection.rb +170 -0
  8. data/lib/active_shipping/lib/country.rb +319 -0
  9. data/lib/active_shipping/lib/error.rb +4 -0
  10. data/lib/active_shipping/lib/post_data.rb +22 -0
  11. data/lib/active_shipping/lib/posts_data.rb +47 -0
  12. data/lib/active_shipping/lib/requires_parameters.rb +16 -0
  13. data/lib/active_shipping/lib/utils.rb +18 -0
  14. data/lib/active_shipping/lib/validateable.rb +76 -0
  15. data/lib/active_shipping/shipping/base.rb +15 -0
  16. data/lib/active_shipping/shipping/carrier.rb +70 -0
  17. data/lib/active_shipping/shipping/carriers.rb +17 -0
  18. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +16 -0
  19. data/lib/active_shipping/shipping/carriers/fedex.rb +315 -0
  20. data/lib/active_shipping/shipping/carriers/shipwire.rb +167 -0
  21. data/lib/active_shipping/shipping/carriers/ups.rb +368 -0
  22. data/lib/active_shipping/shipping/carriers/usps.rb +420 -0
  23. data/lib/active_shipping/shipping/location.rb +100 -0
  24. data/lib/active_shipping/shipping/package.rb +144 -0
  25. data/lib/active_shipping/shipping/rate_estimate.rb +54 -0
  26. data/lib/active_shipping/shipping/rate_response.rb +19 -0
  27. data/lib/active_shipping/shipping/response.rb +49 -0
  28. data/lib/active_shipping/shipping/shipment_event.rb +14 -0
  29. data/lib/active_shipping/shipping/tracking_response.rb +22 -0
  30. data/lib/certs/cacert.pem +7815 -0
  31. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  32. data/lib/vendor/quantified/README.markdown +49 -0
  33. data/lib/vendor/quantified/Rakefile +21 -0
  34. data/lib/vendor/quantified/init.rb +0 -0
  35. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  36. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  37. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  38. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  39. data/lib/vendor/quantified/test/length_test.rb +92 -0
  40. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  41. data/lib/vendor/quantified/test/test_helper.rb +2 -0
  42. data/lib/vendor/test_helper.rb +13 -0
  43. data/lib/vendor/xml_node/README +36 -0
  44. data/lib/vendor/xml_node/Rakefile +21 -0
  45. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  46. data/lib/vendor/xml_node/init.rb +1 -0
  47. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  48. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  49. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  50. data/test/remote/fedex_test.rb +140 -0
  51. data/test/remote/shipwire_test.rb +88 -0
  52. data/test/remote/ups_test.rb +187 -0
  53. data/test/remote/usps_test.rb +184 -0
  54. data/test/test_helper.rb +167 -0
  55. data/test/unit/base_test.rb +18 -0
  56. data/test/unit/carriers/fedex_test.rb +78 -0
  57. data/test/unit/carriers/shipwire_test.rb +130 -0
  58. data/test/unit/carriers/ups_test.rb +81 -0
  59. data/test/unit/carriers/usps_test.rb +170 -0
  60. data/test/unit/location_test.rb +46 -0
  61. data/test/unit/package_test.rb +65 -0
  62. data/test/unit/response_test.rb +10 -0
  63. metadata +123 -0
@@ -0,0 +1,368 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ module ActiveMerchant
4
+ module Shipping
5
+ class UPS < Carrier
6
+ self.retry_safe = true
7
+
8
+ cattr_accessor :default_options
9
+ cattr_reader :name
10
+ @@name = "UPS"
11
+
12
+ TEST_URL = 'https://wwwcie.ups.com'
13
+ LIVE_URL = 'https://www.ups.com'
14
+
15
+ RESOURCES = {
16
+ :rates => 'ups.app/xml/Rate',
17
+ :track => 'ups.app/xml/Track'
18
+ }
19
+
20
+ PICKUP_CODES = {
21
+ :daily_pickup => "01",
22
+ :customer_counter => "03",
23
+ :one_time_pickup => "06",
24
+ :on_call_air => "07",
25
+ :suggested_retail_rates => "11",
26
+ :letter_center => "19",
27
+ :air_service_center => "20"
28
+ }
29
+
30
+ DEFAULT_SERVICES = {
31
+ "01" => "UPS Next Day Air",
32
+ "02" => "UPS Second Day Air",
33
+ "03" => "UPS Ground",
34
+ "07" => "UPS Worldwide Express",
35
+ "08" => "UPS Worldwide Expedited",
36
+ "11" => "UPS Standard",
37
+ "12" => "UPS Three-Day Select",
38
+ "13" => "UPS Next Day Air Saver",
39
+ "14" => "UPS Next Day Air Early A.M.",
40
+ "54" => "UPS Worldwide Express Plus",
41
+ "59" => "UPS Second Day Air A.M.",
42
+ "65" => "UPS Saver",
43
+ "82" => "UPS Today Standard",
44
+ "83" => "UPS Today Dedicated Courier",
45
+ "84" => "UPS Today Intercity",
46
+ "85" => "UPS Today Express",
47
+ "86" => "UPS Today Express Saver"
48
+ }
49
+
50
+ CANADA_ORIGIN_SERVICES = {
51
+ "01" => "UPS Express",
52
+ "02" => "UPS Expedited",
53
+ "14" => "UPS Express Early A.M."
54
+ }
55
+
56
+ MEXICO_ORIGIN_SERVICES = {
57
+ "07" => "UPS Express",
58
+ "08" => "UPS Expedited",
59
+ "54" => "UPS Express Plus"
60
+ }
61
+
62
+ EU_ORIGIN_SERVICES = {
63
+ "07" => "UPS Express",
64
+ "08" => "UPS Expedited"
65
+ }
66
+
67
+ OTHER_NON_US_ORIGIN_SERVICES = {
68
+ "07" => "UPS Express"
69
+ }
70
+
71
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
72
+ EU_COUNTRY_CODES = ["GB", "AT", "BE", "BG", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"]
73
+
74
+ US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
75
+
76
+ def requirements
77
+ [:key, :login, :password]
78
+ end
79
+
80
+ def find_rates(origin, destination, packages, options={})
81
+ origin, destination = upsified_location(origin), upsified_location(destination)
82
+ options = @options.merge(options)
83
+ packages = Array(packages)
84
+ access_request = build_access_request
85
+ rate_request = build_rate_request(origin, destination, packages, options)
86
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
87
+ parse_rate_response(origin, destination, packages, response, options)
88
+ end
89
+
90
+ def find_tracking_info(tracking_number, options={})
91
+ options = @options.update(options)
92
+ access_request = build_access_request
93
+ tracking_request = build_tracking_request(tracking_number, options)
94
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
95
+ parse_tracking_response(response, options)
96
+ end
97
+
98
+ protected
99
+
100
+ def upsified_location(location)
101
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
102
+ atts = {:country => location.state}
103
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
104
+ atts[att] = location.send(att)
105
+ end
106
+ Location.new(atts)
107
+ else
108
+ location
109
+ end
110
+ end
111
+
112
+ def build_access_request
113
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
114
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
115
+ access_request << XmlNode.new('UserId', @options[:login])
116
+ access_request << XmlNode.new('Password', @options[:password])
117
+ end
118
+ xml_request.to_s
119
+ end
120
+
121
+ def build_rate_request(origin, destination, packages, options={})
122
+ packages = Array(packages)
123
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
124
+ root_node << XmlNode.new('Request') do |request|
125
+ request << XmlNode.new('RequestAction', 'Rate')
126
+ request << XmlNode.new('RequestOption', 'Shop')
127
+ # not implemented: 'Rate' RequestOption to specify a single service query
128
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
129
+ end
130
+ root_node << XmlNode.new('PickupType') do |pickup_type|
131
+ pickup_type << XmlNode.new('Code', PICKUP_CODES[options[:pickup_type] || :daily_pickup])
132
+ # not implemented: PickupType/PickupDetails element
133
+ end
134
+ # not implemented: CustomerClassification element
135
+ root_node << XmlNode.new('Shipment') do |shipment|
136
+ # not implemented: Shipment/Description element
137
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
138
+ shipment << build_location_node('ShipTo', destination, options)
139
+ if options[:shipper] and options[:shipper] != origin
140
+ shipment << build_location_node('ShipFrom', origin, options)
141
+ end
142
+
143
+ # not implemented: * Shipment/ShipmentWeight element
144
+ # * Shipment/ReferenceNumber element
145
+ # * Shipment/Service element
146
+ # * Shipment/PickupDate element
147
+ # * Shipment/ScheduledDeliveryDate element
148
+ # * Shipment/ScheduledDeliveryTime element
149
+ # * Shipment/AlternateDeliveryTime element
150
+ # * Shipment/DocumentsOnly element
151
+
152
+ packages.each do |package|
153
+ debugger if package.nil?
154
+
155
+
156
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
157
+
158
+ shipment << XmlNode.new("Package") do |package_node|
159
+
160
+ # not implemented: * Shipment/Package/PackagingType element
161
+ # * Shipment/Package/Description element
162
+
163
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
164
+ packaging_type << XmlNode.new("Code", '02')
165
+ end
166
+
167
+ package_node << XmlNode.new("Dimensions") do |dimensions|
168
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
169
+ units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
170
+ end
171
+ [:length,:width,:height].each do |axis|
172
+ value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
173
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
174
+ end
175
+ end
176
+
177
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
178
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
179
+ units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
180
+ end
181
+
182
+ value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
183
+ package_weight << XmlNode.new("Weight", [value,0.1].max)
184
+ end
185
+
186
+ # not implemented: * Shipment/Package/LargePackageIndicator element
187
+ # * Shipment/Package/ReferenceNumber element
188
+ # * Shipment/Package/PackageServiceOptions element
189
+ # * Shipment/Package/AdditionalHandling element
190
+ end
191
+
192
+ end
193
+
194
+ # not implemented: * Shipment/ShipmentServiceOptions element
195
+ # * Shipment/RateInformation element
196
+
197
+ end
198
+
199
+ end
200
+ xml_request.to_s
201
+ end
202
+
203
+ def build_tracking_request(tracking_number, options={})
204
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
205
+ root_node << XmlNode.new('Request') do |request|
206
+ request << XmlNode.new('RequestAction', 'Track')
207
+ request << XmlNode.new('RequestOption', '1')
208
+ end
209
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
210
+ end
211
+ xml_request.to_s
212
+ end
213
+
214
+ def build_location_node(name,location,options={})
215
+ # not implemented: * Shipment/Shipper/Name element
216
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
217
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
218
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
219
+ location_node = XmlNode.new(name) do |location_node|
220
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
221
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
222
+
223
+ if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
224
+ location_node << XmlNode.new('ShipperNumber', origin_account)
225
+ elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
226
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
227
+ end
228
+
229
+ location_node << XmlNode.new('Address') do |address|
230
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
231
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
232
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
233
+ address << XmlNode.new("City", location.city) unless location.city.blank?
234
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
235
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
236
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
237
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
238
+ address << XmlNode.new("ResidentialAddressIndicator", true) unless location.commercial? # the default should be that UPS returns residential rates for destinations that it doesn't know about
239
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
240
+ end
241
+ end
242
+ end
243
+
244
+ def parse_rate_response(origin, destination, packages, response, options={})
245
+ rates = []
246
+
247
+ xml = REXML::Document.new(response)
248
+ success = response_success?(xml)
249
+ message = response_message(xml)
250
+
251
+ if success
252
+ rate_estimates = []
253
+
254
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
255
+ service_code = rated_shipment.get_text('Service/Code').to_s
256
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
257
+ service_name_for(origin, service_code),
258
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
259
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
260
+ :service_code => service_code,
261
+ :packages => packages)
262
+ end
263
+ end
264
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
265
+ end
266
+
267
+ def parse_tracking_response(response, options={})
268
+ xml = REXML::Document.new(response)
269
+ success = response_success?(xml)
270
+ message = response_message(xml)
271
+
272
+ if success
273
+ tracking_number, origin, destination = nil
274
+ shipment_events = []
275
+
276
+ first_shipment = xml.elements['/*/Shipment']
277
+ first_package = first_shipment.elements['Package']
278
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
279
+
280
+ origin, destination = %w{Shipper ShipTo}.map do |location|
281
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
282
+ end
283
+
284
+ activities = first_package.get_elements('Activity')
285
+ unless activities.empty?
286
+ shipment_events = activities.map do |activity|
287
+ description = activity.get_text('Status/StatusType/Description').to_s
288
+ zoneless_time = if (time = activity.get_text('Time')) &&
289
+ (date = activity.get_text('Date'))
290
+ time, date = time.to_s, date.to_s
291
+ hour, minute, second = time.scan(/\d{2}/)
292
+ year, month, day = date[0..3], date[4..5], date[6..7]
293
+ Time.utc(year, month, day, hour, minute, second)
294
+ end
295
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
296
+ ShipmentEvent.new(description, zoneless_time, location)
297
+ end
298
+
299
+ shipment_events = shipment_events.sort_by(&:time)
300
+
301
+ if origin
302
+ first_event = shipment_events[0]
303
+ same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
304
+ same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
305
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
306
+ if same_country and same_or_blank_city
307
+ shipment_events[0] = origin_event
308
+ else
309
+ shipment_events.unshift(origin_event)
310
+ end
311
+ end
312
+ if shipment_events.last.name.downcase == 'delivered'
313
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
314
+ end
315
+ end
316
+
317
+ end
318
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
319
+ :xml => response,
320
+ :request => last_request,
321
+ :shipment_events => shipment_events,
322
+ :origin => origin,
323
+ :destination => destination,
324
+ :tracking_number => tracking_number)
325
+ end
326
+
327
+ def location_from_address_node(address)
328
+ return nil unless address
329
+ Location.new(
330
+ :country => node_text_or_nil(address.elements['CountryCode']),
331
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
332
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
333
+ :city => node_text_or_nil(address.elements['City']),
334
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
335
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
336
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
337
+ )
338
+ end
339
+
340
+ def response_success?(xml)
341
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
342
+ end
343
+
344
+ def response_message(xml)
345
+ xml.get_text('/*/Response/ResponseStatusDescription | /*/Response/Error/ErrorDescription').to_s
346
+ end
347
+
348
+ def commit(action, request, test = false)
349
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
350
+ end
351
+
352
+
353
+ def service_name_for(origin, code)
354
+ origin = origin.country_code(:alpha2)
355
+
356
+ name = case origin
357
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
358
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
359
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
360
+ end
361
+
362
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
363
+ name ||= DEFAULT_SERVICES[code]
364
+ end
365
+
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,420 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require 'cgi'
3
+
4
+ module ActiveMerchant
5
+ module Shipping
6
+
7
+ # After getting an API login from USPS (looks like '123YOURNAME456'),
8
+ # run the following test:
9
+ #
10
+ # usps = USPS.new(:login => '123YOURNAME456', :test => true)
11
+ # usps.valid_credentials?
12
+ #
13
+ # This will send a test request to the USPS test servers, which they ask you
14
+ # to do before they put your API key in production mode.
15
+ class USPS < Carrier
16
+ self.retry_safe = true
17
+
18
+ cattr_reader :name
19
+ @@name = "USPS"
20
+
21
+ LIVE_DOMAIN = 'production.shippingapis.com'
22
+ LIVE_RESOURCE = 'ShippingAPI.dll'
23
+
24
+ TEST_DOMAINS = { #indexed by security; e.g. TEST_DOMAINS[USE_SSL[:rates]]
25
+ true => 'secure.shippingapis.com',
26
+ false => 'testing.shippingapis.com'
27
+ }
28
+
29
+ TEST_RESOURCE = 'ShippingAPITest.dll'
30
+
31
+ API_CODES = {
32
+ :us_rates => 'RateV3',
33
+ :world_rates => 'IntlRate',
34
+ :test => 'CarrierPickupAvailability'
35
+ }
36
+ USE_SSL = {
37
+ :us_rates => false,
38
+ :world_rates => false,
39
+ :test => true
40
+ }
41
+ CONTAINERS = {
42
+ :envelope => 'Flat Rate Envelope',
43
+ :box => 'Flat Rate Box'
44
+ }
45
+ MAIL_TYPES = {
46
+ :package => 'Package',
47
+ :postcard => 'Postcards or aerogrammes',
48
+ :matter_for_the_blind => 'Matter for the blind',
49
+ :envelope => 'Envelope'
50
+ }
51
+ PACKAGE_PROPERTIES = {
52
+ 'ZipOrigination' => :origin_zip,
53
+ 'ZipDestination' => :destination_zip,
54
+ 'Pounds' => :pounds,
55
+ 'Ounces' => :ounces,
56
+ 'Container' => :container,
57
+ 'Size' => :size,
58
+ 'Machinable' => :machinable,
59
+ 'Zone' => :zone,
60
+ 'Postage' => :postage,
61
+ 'Restrictions' => :restrictions
62
+ }
63
+ POSTAGE_PROPERTIES = {
64
+ 'MailService' => :service,
65
+ 'Rate' => :rate
66
+ }
67
+ US_SERVICES = {
68
+ :first_class => 'FIRST CLASS',
69
+ :priority => 'PRIORITY',
70
+ :express => 'EXPRESS',
71
+ :bpm => 'BPM',
72
+ :parcel => 'PARCEL',
73
+ :media => 'MEDIA',
74
+ :library => 'LIBRARY',
75
+ :all => 'ALL'
76
+ }
77
+
78
+ # TODO: get rates for "U.S. possessions and Trust Territories" like Guam, etc. via domestic rates API: http://www.usps.com/ncsc/lookups/abbr_state.txt
79
+ # TODO: figure out how USPS likes to say "Ivory Coast"
80
+ #
81
+ # Country names:
82
+ # http://pe.usps.gov/text/Imm/immctry.htm
83
+ COUNTRY_NAME_CONVERSIONS = {
84
+ "BA" => "Bosnia-Herzegovina",
85
+ "CD" => "Congo, Democratic Republic of the",
86
+ "CG" => "Congo (Brazzaville),Republic of the",
87
+ "CI" => "Côte d'Ivoire (Ivory Coast)",
88
+ "CK" => "Cook Islands (New Zealand)",
89
+ "FK" => "Falkland Islands",
90
+ "GB" => "Great Britain and Northern Ireland",
91
+ "GE" => "Georgia, Republic of",
92
+ "IR" => "Iran",
93
+ "KN" => "Saint Kitts (St. Christopher and Nevis)",
94
+ "KP" => "North Korea (Korea, Democratic People's Republic of)",
95
+ "KR" => "South Korea (Korea, Republic of)",
96
+ "LA" => "Laos",
97
+ "LY" => "Libya",
98
+ "MC" => "Monaco (France)",
99
+ "MD" => "Moldova",
100
+ "MK" => "Macedonia, Republic of",
101
+ "MM" => "Burma",
102
+ "PN" => "Pitcairn Island",
103
+ "RU" => "Russia",
104
+ "SK" => "Slovak Republic",
105
+ "TK" => "Tokelau (Union) Group (Western Samoa)",
106
+ "TW" => "Taiwan",
107
+ "TZ" => "Tanzania",
108
+ "VA" => "Vatican City",
109
+ "VG" => "British Virgin Islands",
110
+ "VN" => "Vietnam",
111
+ "WF" => "Wallis and Futuna Islands",
112
+ "WS" => "Western Samoa"
113
+ }
114
+
115
+ def self.size_code_for(package)
116
+ total = package.inches(:length) + package.inches(:girth)
117
+ if total <= 84
118
+ return 'REGULAR'
119
+ elsif total <= 108
120
+ return 'LARGE'
121
+ else # <= 130
122
+ return 'OVERSIZE'
123
+ end
124
+ end
125
+
126
+ # from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
127
+ #
128
+ # package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
129
+ # Defaults to false.
130
+ def self.package_machinable?(package, options={})
131
+ at_least_minimum = package.inches(:length) >= 6.0 &&
132
+ package.inches(:width) >= 3.0 &&
133
+ package.inches(:height) >= 0.25 &&
134
+ package.ounces >= 6.0
135
+ at_most_maximum = package.inches(:length) <= 34.0 &&
136
+ package.inches(:width) <= 17.0 &&
137
+ package.inches(:height) <= 17.0 &&
138
+ package.pounds <= (package.options[:books] ? 25.0 : 35.0)
139
+ at_least_minimum && at_most_maximum
140
+ end
141
+
142
+ def requirements
143
+ [:login]
144
+ end
145
+
146
+ def find_rates(origin, destination, packages, options = {})
147
+ options = @options.merge(options)
148
+
149
+ origin = Location.from(origin)
150
+ destination = Location.from(destination)
151
+ packages = Array(packages)
152
+
153
+ #raise ArgumentError.new("USPS packages must originate in the U.S.") unless ['US',nil].include?(origin.country_code(:alpha2))
154
+
155
+
156
+ # domestic or international?
157
+
158
+ response = if ['US',nil].include?(destination.country_code(:alpha2))
159
+ us_rates(origin, destination, packages, options)
160
+ else
161
+ world_rates(origin, destination, packages, options)
162
+ end
163
+ end
164
+
165
+ def valid_credentials?
166
+ # Cannot test with find_rates because USPS doesn't allow that in test mode
167
+ test_mode? ? canned_address_verification_works? : super
168
+ end
169
+
170
+ def maximum_weight
171
+ Mass.new(70, :pounds)
172
+ end
173
+
174
+ protected
175
+
176
+ def us_rates(origin, destination, packages, options={})
177
+ request = build_us_rate_request(packages, origin.zip, destination.zip, options)
178
+ # never use test mode; rate requests just won't work on test servers
179
+ parse_rate_response origin, destination, packages, commit(:us_rates,request,false), options
180
+ end
181
+
182
+ def world_rates(origin, destination, packages, options={})
183
+ request = build_world_rate_request(packages, destination.country)
184
+ # never use test mode; rate requests just won't work on test servers
185
+ parse_rate_response origin, destination, packages, commit(:world_rates,request,false), options
186
+ end
187
+
188
+ # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
189
+ def canned_address_verification_works?
190
+ request = "%3CCarrierPickupAvailabilityRequest%20USERID=%22#{URI.encode(@options[:login])}%22%3E%20%0A%3CFirmName%3EABC%20Corp.%3C/FirmName%3E%20%0A%3CSuiteOrApt%3ESuite%20777%3C/SuiteOrApt%3E%20%0A%3CAddress2%3E1390%20Market%20Street%3C/Address2%3E%20%0A%3CUrbanization%3E%3C/Urbanization%3E%20%0A%3CCity%3EHouston%3C/City%3E%20%0A%3CState%3ETX%3C/State%3E%20%0A%3CZIP5%3E77058%3C/ZIP5%3E%20%0A%3CZIP4%3E1234%3C/ZIP4%3E%20%0A%3C/CarrierPickupAvailabilityRequest%3E%0A"
191
+ # expected_hash = {"CarrierPickupAvailabilityResponse"=>{"City"=>"HOUSTON", "Address2"=>"1390 Market Street", "FirmName"=>"ABC Corp.", "State"=>"TX", "Date"=>"3/1/2004", "DayOfWeek"=>"Monday", "Urbanization"=>nil, "ZIP4"=>"1234", "ZIP5"=>"77058", "CarrierRoute"=>"C", "SuiteOrApt"=>"Suite 777"}}
192
+ xml = REXML::Document.new(commit(:test, request, true))
193
+ xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'HOUSTON' &&
194
+ xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '1390 Market Street'
195
+ end
196
+
197
+ # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
198
+ # :media, :library, :all]. defaults to :all.
199
+ # options[:container] -- One of [:envelope, :box]. defaults to neither (this field has
200
+ # special meaning in the USPS API).
201
+ # options[:books] -- Either true or false. Packages of books or other printed matter
202
+ # have a lower weight limit to be considered machinable.
203
+ # package.options[:machinable] -- Either true or false. Overrides the detection of
204
+ # "machinability" entirely.
205
+ def build_us_rate_request(packages, origin_zip, destination_zip, options={})
206
+ packages = Array(packages)
207
+ request = XmlNode.new('RateV3Request', :USERID => @options[:login]) do |rate_request|
208
+ packages.each_with_index do |p,id|
209
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
210
+ package << XmlNode.new('Service', US_SERVICES[options[:service] || :all])
211
+ package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
212
+ package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
213
+ package << XmlNode.new('Pounds', 0)
214
+ package << XmlNode.new('Ounces', "%0.1f" % [p.ounces,1].max)
215
+ if p.options[:container] and [nil,:all,:express,:priority].include? p.service
216
+ package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
217
+ end
218
+ package << XmlNode.new('Size', USPS.size_code_for(p))
219
+ package << XmlNode.new('Width', p.inches(:width))
220
+ package << XmlNode.new('Length', p.inches(:length))
221
+ package << XmlNode.new('Height', p.inches(:height))
222
+ package << XmlNode.new('Girth', p.inches(:girth))
223
+ is_machinable = if p.options.has_key?(:machinable)
224
+ p.options[:machinable] ? true : false
225
+ else
226
+ USPS.package_machinable?(p)
227
+ end
228
+ package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
229
+ end
230
+ end
231
+ end
232
+ URI.encode(save_request(request.to_s))
233
+ end
234
+
235
+ # important difference with international rate requests:
236
+ # * services are not given in the request
237
+ # * package sizes are not given in the request
238
+ # * services are returned in the response along with restrictions of size
239
+ # * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
240
+ #
241
+ #
242
+ # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
243
+ # Defaults to :package.
244
+ def build_world_rate_request(packages, destination_country)
245
+ country = COUNTRY_NAME_CONVERSIONS[destination_country.code(:alpha2).first.value] || destination_country.name
246
+ request = XmlNode.new('IntlRateRequest', :USERID => @options[:login]) do |rate_request|
247
+ packages.each_index do |id|
248
+ p = packages[id]
249
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
250
+ package << XmlNode.new('Pounds', 0)
251
+ package << XmlNode.new('Ounces', [p.ounces,1].max.ceil) #takes an integer for some reason, must be rounded UP
252
+ package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
253
+ package << XmlNode.new('ValueOfContents', p.value / 100.0) if p.value && p.currency == 'USD'
254
+ package << XmlNode.new('Country') do |node|
255
+ node.cdata = country
256
+ end
257
+ end
258
+ end
259
+ end
260
+ URI.encode(save_request(request.to_s))
261
+ end
262
+
263
+ def parse_rate_response(origin, destination, packages, response, options={})
264
+ success = true
265
+ message = ''
266
+ rate_hash = {}
267
+
268
+ xml = REXML::Document.new(response)
269
+
270
+ if error = xml.elements['/Error']
271
+ success = false
272
+ message = error.elements['Description'].text
273
+ else
274
+ xml.elements.each('/*/Package') do |package|
275
+ if package.elements['Error']
276
+ success = false
277
+ message = package.get_text('Error/Description').to_s
278
+ break
279
+ end
280
+ end
281
+
282
+ if success
283
+ rate_hash = rates_from_response_node(xml, packages)
284
+ unless rate_hash
285
+ success = false
286
+ message = "Unknown root node in XML response: '#{root_node_name}'"
287
+ end
288
+ end
289
+
290
+ end
291
+
292
+ rate_estimates = rate_hash.keys.map do |service_name|
293
+ RateEstimate.new(origin,destination,@@name,"USPS #{service_name}",
294
+ :package_rates => rate_hash[service_name][:package_rates],
295
+ :service_code => rate_hash[service_name][:service_code],
296
+ :currency => 'USD')
297
+ end
298
+ rate_estimates.reject! {|e| e.package_count != packages.length}
299
+ rate_estimates = rate_estimates.sort_by(&:total_price)
300
+
301
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
302
+ end
303
+
304
+ def rates_from_response_node(response_node, packages)
305
+ rate_hash = {}
306
+ return false unless (root_node = response_node.elements['/IntlRateResponse | /RateV3Response'])
307
+ domestic = (root_node.name == 'RateV3Response')
308
+
309
+ domestic_elements = ['Postage', 'CLASSID', 'MailService', 'Rate']
310
+ international_elements = ['Service', 'ID', 'SvcDescription', 'Postage']
311
+ service_node, service_code_node, service_name_node, rate_node = domestic ? domestic_elements : international_elements
312
+
313
+ root_node.each_element('Package') do |package_node|
314
+ package_index = package_node.attributes['ID'].to_i
315
+
316
+ package_node.each_element(service_node) do |service_response_node|
317
+ service_name = service_response_node.get_text(service_name_node).to_s
318
+
319
+ # aggregate specific package rates into a service-centric RateEstimate
320
+ # first package with a given service name will initialize these;
321
+ # later packages with same service will add to them
322
+ this_service = rate_hash[service_name] ||= {}
323
+ this_service[:service_code] ||= service_response_node.attributes[service_code_node]
324
+ package_rates = this_service[:package_rates] ||= []
325
+ this_package_rate = {:package => (this_package = packages[package_index]),
326
+ :rate => Package.cents_from(service_response_node.get_text(rate_node).to_s.to_f)}
327
+
328
+ package_rates << this_package_rate if package_valid_for_service(this_package,service_response_node)
329
+ end
330
+ end
331
+ rate_hash
332
+ end
333
+
334
+ def package_valid_for_service(package, service_node)
335
+ return true if service_node.elements['MaxWeight'].nil?
336
+ max_weight = service_node.get_text('MaxWeight').to_s.to_f
337
+ name = service_node.get_text('SvcDescription | MailService').to_s.downcase
338
+
339
+ if name =~ /flat.rate.box/ #domestic or international flat rate box
340
+ # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
341
+ return (package_valid_for_max_dimensions(package,
342
+ :weight => max_weight, #domestic apparently has no weight restriction
343
+ :length => 11.0,
344
+ :width => 8.5,
345
+ :height => 5.5) or
346
+ package_valid_for_max_dimensions(package,
347
+ :weight => max_weight,
348
+ :length => 13.625,
349
+ :width => 11.875,
350
+ :height => 3.375))
351
+ elsif name =~ /flat.rate.envelope/
352
+ return package_valid_for_max_dimensions(package,
353
+ :weight => max_weight,
354
+ :length => 12.5,
355
+ :width => 9.5,
356
+ :height => 0.75)
357
+ elsif service_node.elements['MailService'] # domestic non-flat rates
358
+ return true
359
+ else #international non-flat rates
360
+ # Some sample english that this is required to parse:
361
+ #
362
+ # 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
363
+ # 'Max. length 24", Max. length, height, depth combined 36"'
364
+ #
365
+ sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
366
+ tokens = sentence.downcase.split(/[^\d]*"/).reject {|t| t.empty?}
367
+ max_dimensions = {:weight => max_weight}
368
+ single_axis_values = []
369
+ tokens.each do |token|
370
+ axis_sum = [/length/,/width/,/height/,/depth/].sum {|regex| (token =~ regex) ? 1 : 0}
371
+ unless axis_sum == 0
372
+ value = token[/\d+$/].to_f
373
+ if axis_sum == 3
374
+ max_dimensions[:length_plus_width_plus_height] = value
375
+ elsif token =~ /girth/ and axis_sum == 1
376
+ max_dimensions[:length_plus_girth] = value
377
+ else
378
+ single_axis_values << value
379
+ end
380
+ end
381
+ end
382
+ single_axis_values.sort!.reverse!
383
+ [:length, :width, :height].each_with_index do |axis,i|
384
+ max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
385
+ end
386
+ return package_valid_for_max_dimensions(package, max_dimensions)
387
+ end
388
+ end
389
+
390
+ def package_valid_for_max_dimensions(package,dimensions)
391
+ valid = ((not ([:length,:width,:height].map {|dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f}.include?(false))) and
392
+ (dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
393
+ (dimensions[:length_plus_girth].nil? or
394
+ dimensions[:length_plus_girth].to_f >=
395
+ package.inches(:length) + package.inches(:girth)) and
396
+ (dimensions[:length_plus_width_plus_height].nil? or
397
+ dimensions[:length_plus_width_plus_height].to_f >=
398
+ package.inches(:length) + package.inches(:width) + package.inches(:height)))
399
+
400
+ return valid
401
+ end
402
+
403
+ def commit(action, request, test = false)
404
+ ssl_get(request_url(action, request, test))
405
+ end
406
+
407
+ def request_url(action, request, test)
408
+ scheme = USE_SSL[action] ? 'https://' : 'http://'
409
+ host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
410
+ resource = test ? TEST_RESOURCE : LIVE_RESOURCE
411
+ "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
412
+ end
413
+
414
+ def strip_zip(zip)
415
+ zip.to_s.scan(/\d{5}/).first || zip
416
+ end
417
+
418
+ end
419
+ end
420
+ end