active_shipping 0.12.6 → 1.0.0.pre1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -0
  3. data.tar.gz.sig +0 -0
  4. data/{CHANGELOG → CHANGELOG.md} +6 -2
  5. data/CONTRIBUTING.md +32 -0
  6. data/{README.markdown → README.md} +45 -61
  7. data/lib/active_shipping.rb +20 -28
  8. data/lib/active_shipping/carrier.rb +82 -0
  9. data/lib/active_shipping/carriers.rb +33 -0
  10. data/lib/active_shipping/carriers/benchmark_carrier.rb +31 -0
  11. data/lib/active_shipping/carriers/bogus_carrier.rb +12 -0
  12. data/lib/active_shipping/carriers/canada_post.rb +253 -0
  13. data/lib/active_shipping/carriers/canada_post_pws.rb +870 -0
  14. data/lib/active_shipping/carriers/fedex.rb +579 -0
  15. data/lib/active_shipping/carriers/kunaki.rb +164 -0
  16. data/lib/active_shipping/carriers/new_zealand_post.rb +262 -0
  17. data/lib/active_shipping/carriers/shipwire.rb +181 -0
  18. data/lib/active_shipping/carriers/stamps.rb +861 -0
  19. data/lib/active_shipping/carriers/ups.rb +648 -0
  20. data/lib/active_shipping/carriers/usps.rb +642 -0
  21. data/lib/active_shipping/errors.rb +7 -0
  22. data/lib/active_shipping/label_response.rb +23 -0
  23. data/lib/active_shipping/location.rb +149 -0
  24. data/lib/active_shipping/package.rb +241 -0
  25. data/lib/active_shipping/rate_estimate.rb +64 -0
  26. data/lib/active_shipping/rate_response.rb +13 -0
  27. data/lib/active_shipping/response.rb +41 -0
  28. data/lib/active_shipping/shipment_event.rb +17 -0
  29. data/lib/active_shipping/shipment_packer.rb +73 -0
  30. data/lib/active_shipping/shipping_response.rb +12 -0
  31. data/lib/active_shipping/tracking_response.rb +52 -0
  32. data/lib/active_shipping/version.rb +1 -1
  33. data/lib/vendor/quantified/test/length_test.rb +2 -2
  34. data/lib/vendor/xml_node/test/test_parsing.rb +1 -1
  35. metadata +58 -36
  36. metadata.gz.sig +0 -0
  37. data/lib/active_shipping/shipping/base.rb +0 -13
  38. data/lib/active_shipping/shipping/carrier.rb +0 -84
  39. data/lib/active_shipping/shipping/carriers.rb +0 -23
  40. data/lib/active_shipping/shipping/carriers/benchmark_carrier.rb +0 -33
  41. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +0 -14
  42. data/lib/active_shipping/shipping/carriers/canada_post.rb +0 -257
  43. data/lib/active_shipping/shipping/carriers/canada_post_pws.rb +0 -874
  44. data/lib/active_shipping/shipping/carriers/fedex.rb +0 -581
  45. data/lib/active_shipping/shipping/carriers/kunaki.rb +0 -166
  46. data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +0 -262
  47. data/lib/active_shipping/shipping/carriers/shipwire.rb +0 -184
  48. data/lib/active_shipping/shipping/carriers/stamps.rb +0 -864
  49. data/lib/active_shipping/shipping/carriers/ups.rb +0 -650
  50. data/lib/active_shipping/shipping/carriers/usps.rb +0 -649
  51. data/lib/active_shipping/shipping/errors.rb +0 -9
  52. data/lib/active_shipping/shipping/label_response.rb +0 -25
  53. data/lib/active_shipping/shipping/location.rb +0 -152
  54. data/lib/active_shipping/shipping/package.rb +0 -243
  55. data/lib/active_shipping/shipping/rate_estimate.rb +0 -66
  56. data/lib/active_shipping/shipping/rate_response.rb +0 -15
  57. data/lib/active_shipping/shipping/response.rb +0 -43
  58. data/lib/active_shipping/shipping/shipment_event.rb +0 -19
  59. data/lib/active_shipping/shipping/shipment_packer.rb +0 -75
  60. data/lib/active_shipping/shipping/shipping_response.rb +0 -14
  61. data/lib/active_shipping/shipping/tracking_response.rb +0 -54
@@ -0,0 +1,642 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ module ActiveShipping
4
+ # After getting an API login from USPS (looks like '123YOURNAME456'),
5
+ # run the following test:
6
+ #
7
+ # usps = USPS.new(:login => '123YOURNAME456', :test => true)
8
+ # usps.valid_credentials?
9
+ #
10
+ # This will send a test request to the USPS test servers, which they ask you
11
+ # to do before they put your API key in production mode.
12
+ class USPS < Carrier
13
+ EventDetails = Struct.new(:description, :time, :zoneless_time, :location)
14
+ EVENT_MESSAGE_PATTERNS = [
15
+ /^(.*), (\w+ \d{1,2}, \d{4}, \d{1,2}:\d\d [ap]m), (.*), (\w\w) (\d{5})$/i,
16
+ /^Your item \w{2,3} (out for delivery|delivered) at (\d{1,2}:\d\d [ap]m on \w+ \d{1,2}, \d{4}) in (.*), (\w\w) (\d{5})\.$/i
17
+ ]
18
+ self.retry_safe = true
19
+
20
+ cattr_reader :name
21
+ @@name = "USPS"
22
+
23
+ LIVE_DOMAIN = 'production.shippingapis.com'
24
+ LIVE_RESOURCE = 'ShippingAPI.dll'
25
+
26
+ TEST_DOMAINS = { # indexed by security; e.g. TEST_DOMAINS[USE_SSL[:rates]]
27
+ true => 'secure.shippingapis.com',
28
+ false => 'testing.shippingapis.com'
29
+ }
30
+
31
+ TEST_RESOURCE = 'ShippingAPITest.dll'
32
+
33
+ API_CODES = {
34
+ :us_rates => 'RateV4',
35
+ :world_rates => 'IntlRateV2',
36
+ :test => 'CarrierPickupAvailability',
37
+ :track => 'TrackV2'
38
+ }
39
+ USE_SSL = {
40
+ :us_rates => false,
41
+ :world_rates => false,
42
+ :test => true,
43
+ :track => false
44
+ }
45
+
46
+ CONTAINERS = {
47
+ rectangular: 'RECTANGULAR',
48
+ variable: 'VARIABLE',
49
+ box: 'FLAT RATE BOX',
50
+ box_large: 'LG FLAT RATE BOX',
51
+ box_medium: 'MD FLAT RATE BOX',
52
+ box_small: 'SM FLAT RATE BOX',
53
+ envelope: 'FLAT RATE ENVELOPE',
54
+ envelope_legal: 'LEGAL FLAT RATE ENVELOPE',
55
+ envelope_padded: 'PADDED FLAT RATE ENVELOPE',
56
+ envelope_gift_card: 'GIFT CARD FLAT RATE ENVELOPE',
57
+ envelope_window: 'WINDOW FLAT RATE ENVELOPE',
58
+ envelope_small: 'SM FLAT RATE ENVELOPE',
59
+ package_service: 'PACKAGE SERVICE'
60
+ }
61
+
62
+ MAIL_TYPES = {
63
+ :package => 'Package',
64
+ :postcard => 'Postcards or aerogrammes',
65
+ :matter_for_the_blind => 'Matter for the blind',
66
+ :envelope => 'Envelope'
67
+ }
68
+
69
+ PACKAGE_PROPERTIES = {
70
+ 'ZipOrigination' => :origin_zip,
71
+ 'ZipDestination' => :destination_zip,
72
+ 'Pounds' => :pounds,
73
+ 'Ounces' => :ounces,
74
+ 'Container' => :container,
75
+ 'Size' => :size,
76
+ 'Machinable' => :machinable,
77
+ 'Zone' => :zone,
78
+ 'Postage' => :postage,
79
+ 'Restrictions' => :restrictions
80
+ }
81
+ POSTAGE_PROPERTIES = {
82
+ 'MailService' => :service,
83
+ 'Rate' => :rate
84
+ }
85
+ US_SERVICES = {
86
+ :first_class => 'FIRST CLASS',
87
+ :priority => 'PRIORITY',
88
+ :express => 'EXPRESS',
89
+ :bpm => 'BPM',
90
+ :parcel => 'PARCEL',
91
+ :media => 'MEDIA',
92
+ :library => 'LIBRARY',
93
+ :online => 'ONLINE',
94
+ :plus => 'PLUS',
95
+ :all => 'ALL'
96
+ }
97
+ DEFAULT_SERVICE = Hash.new(:all).update(
98
+ :base => :online,
99
+ :plus => :plus
100
+ )
101
+ DOMESTIC_RATE_FIELD = Hash.new('Rate').update(
102
+ :base => 'CommercialRate',
103
+ :plus => 'CommercialPlusRate'
104
+ )
105
+ INTERNATIONAL_RATE_FIELD = Hash.new('Postage').update(
106
+ :base => 'CommercialPostage',
107
+ :plus => 'CommercialPlusPostage'
108
+ )
109
+ COMMERCIAL_FLAG_NAME = {
110
+ :base => 'CommercialFlag',
111
+ :plus => 'CommercialPlusFlag'
112
+ }
113
+ FIRST_CLASS_MAIL_TYPES = {
114
+ :letter => 'LETTER',
115
+ :flat => 'FLAT',
116
+ :parcel => 'PARCEL',
117
+ :post_card => 'POSTCARD',
118
+ :package_service => 'PACKAGESERVICE'
119
+ }
120
+
121
+ # Array of U.S. possessions according to USPS: https://www.usps.com/ship/official-abbreviations.htm
122
+ US_POSSESSIONS = %w(AS FM GU MH MP PW PR VI)
123
+
124
+ # TODO: figure out how USPS likes to say "Ivory Coast"
125
+ #
126
+ # Country names:
127
+ # http://pe.usps.gov/text/Imm/immctry.htm
128
+ COUNTRY_NAME_CONVERSIONS = {
129
+ "BA" => "Bosnia-Herzegovina",
130
+ "CD" => "Congo, Democratic Republic of the",
131
+ "CG" => "Congo (Brazzaville),Republic of the",
132
+ "CI" => "Côte d'Ivoire (Ivory Coast)",
133
+ "CK" => "Cook Islands (New Zealand)",
134
+ "FK" => "Falkland Islands",
135
+ "GB" => "Great Britain and Northern Ireland",
136
+ "GE" => "Georgia, Republic of",
137
+ "IR" => "Iran",
138
+ "KN" => "Saint Kitts (St. Christopher and Nevis)",
139
+ "KP" => "North Korea (Korea, Democratic People's Republic of)",
140
+ "KR" => "South Korea (Korea, Republic of)",
141
+ "LA" => "Laos",
142
+ "LY" => "Libya",
143
+ "MC" => "Monaco (France)",
144
+ "MD" => "Moldova",
145
+ "MK" => "Macedonia, Republic of",
146
+ "MM" => "Burma",
147
+ "PN" => "Pitcairn Island",
148
+ "RU" => "Russia",
149
+ "SK" => "Slovak Republic",
150
+ "TK" => "Tokelau (Union) Group (Western Samoa)",
151
+ "TW" => "Taiwan",
152
+ "TZ" => "Tanzania",
153
+ "VA" => "Vatican City",
154
+ "VG" => "British Virgin Islands",
155
+ "VN" => "Vietnam",
156
+ "WF" => "Wallis and Futuna Islands",
157
+ "WS" => "Western Samoa"
158
+ }
159
+
160
+ STATUS_NODE_PATTERNS = %w(
161
+ Error/Description
162
+ */TrackInfo/Error/Description
163
+ )
164
+
165
+ RESPONSE_ERROR_MESSAGES = [
166
+ /There is no record of that mail item/,
167
+ /This Information has not been included in this Test Server\./,
168
+ /Delivery status information is not available/
169
+ ]
170
+
171
+ ESCAPING_AND_SYMBOLS = /&amp;lt;\S*&amp;gt;/
172
+ LEADING_USPS = /^USPS/
173
+ TRAILING_ASTERISKS = /\*+$/
174
+ SERVICE_NAME_SUBSTITUTIONS = /#{ESCAPING_AND_SYMBOLS}|#{LEADING_USPS}|#{TRAILING_ASTERISKS}/
175
+
176
+ def find_tracking_info(tracking_number, options = {})
177
+ options = @options.update(options)
178
+ tracking_request = build_tracking_request(tracking_number, options)
179
+ response = commit(:track, tracking_request, (options[:test] || false))
180
+ parse_tracking_response(response, options)
181
+ end
182
+
183
+ def self.size_code_for(package)
184
+ if package.inches(:max) <= 12
185
+ 'REGULAR'
186
+ else
187
+ 'LARGE'
188
+ end
189
+ end
190
+
191
+ # from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
192
+ #
193
+ # package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
194
+ # Defaults to false.
195
+ def self.package_machinable?(package, options = {})
196
+ at_least_minimum = package.inches(:length) >= 6.0 &&
197
+ package.inches(:width) >= 3.0 &&
198
+ package.inches(:height) >= 0.25 &&
199
+ package.ounces >= 6.0
200
+ at_most_maximum = package.inches(:length) <= 34.0 &&
201
+ package.inches(:width) <= 17.0 &&
202
+ package.inches(:height) <= 17.0 &&
203
+ package.pounds <= (package.options[:books] ? 25.0 : 35.0)
204
+ at_least_minimum && at_most_maximum
205
+ end
206
+
207
+ def requirements
208
+ [:login]
209
+ end
210
+
211
+ def find_rates(origin, destination, packages, options = {})
212
+ options = @options.merge(options)
213
+
214
+ origin = Location.from(origin)
215
+ destination = Location.from(destination)
216
+ packages = Array(packages)
217
+
218
+ domestic_codes = US_POSSESSIONS + ['US', nil]
219
+ if domestic_codes.include?(destination.country_code(:alpha2))
220
+ us_rates(origin, destination, packages, options)
221
+ else
222
+ world_rates(origin, destination, packages, options)
223
+ end
224
+ end
225
+
226
+ def valid_credentials?
227
+ # Cannot test with find_rates because USPS doesn't allow that in test mode
228
+ test_mode? ? canned_address_verification_works? : super
229
+ end
230
+
231
+ def maximum_weight
232
+ Mass.new(70, :pounds)
233
+ end
234
+
235
+ def extract_event_details(message)
236
+ return EventDetails.new unless EVENT_MESSAGE_PATTERNS.any? { |pattern| message =~ pattern }
237
+ description = $1.upcase
238
+ timestamp = $2
239
+ city = $3
240
+ state = $4
241
+ zip_code = $5
242
+
243
+ time = Time.parse(timestamp)
244
+ zoneless_time = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec)
245
+ location = Location.new(city: city, state: state, postal_code: zip_code, country: 'USA')
246
+ EventDetails.new(description, time, zoneless_time, location)
247
+ end
248
+
249
+ protected
250
+
251
+ def build_tracking_request(tracking_number, options = {})
252
+ xml_request = XmlNode.new('TrackRequest', 'USERID' => @options[:login]) do |root_node|
253
+ root_node << XmlNode.new('TrackID', :ID => tracking_number)
254
+ end
255
+ URI.encode(xml_request.to_s)
256
+ end
257
+
258
+ def us_rates(origin, destination, packages, options = {})
259
+ request = build_us_rate_request(packages, origin.zip, destination.zip, options)
260
+ # never use test mode; rate requests just won't work on test servers
261
+ parse_rate_response origin, destination, packages, commit(:us_rates, request, false), options
262
+ end
263
+
264
+ def world_rates(origin, destination, packages, options = {})
265
+ request = build_world_rate_request(packages, destination, options)
266
+ # never use test mode; rate requests just won't work on test servers
267
+ parse_rate_response origin, destination, packages, commit(:world_rates, request, false), options
268
+ end
269
+
270
+ # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
271
+ def canned_address_verification_works?
272
+ return false unless @options[:login]
273
+ request = <<-EOF
274
+ <?xml version="1.0" encoding="UTF-8"?>
275
+ <CarrierPickupAvailabilityRequest USERID="#{URI.encode(@options[:login])}">
276
+ <FirmName>Shopifolk</FirmName>
277
+ <SuiteOrApt>Suite 0</SuiteOrApt>
278
+ <Address2>18 Fair Ave</Address2>
279
+ <Urbanization />
280
+ <City>San Francisco</City>
281
+ <State>CA</State>
282
+ <ZIP5>94110</ZIP5>
283
+ <ZIP4>9411</ZIP4>
284
+ </CarrierPickupAvailabilityRequest>
285
+ EOF
286
+ xml = REXML::Document.new(commit(:test, URI.encode(request), true))
287
+ xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'SAN FRANCISCO' && xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '18 FAIR AVE'
288
+ end
289
+
290
+ # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
291
+ # :media, :library, :online, :plus, :all]. defaults to :all.
292
+ # options[:books] -- Either true or false. Packages of books or other printed matter
293
+ # have a lower weight limit to be considered machinable.
294
+ # package.options[:container] -- Can be :rectangular, :variable, or a flat rate container
295
+ # defined in CONTAINERS.
296
+ # package.options[:machinable] -- Either true or false. Overrides the detection of
297
+ # "machinability" entirely.
298
+ def build_us_rate_request(packages, origin_zip, destination_zip, options = {})
299
+ packages = Array(packages)
300
+ request = XmlNode.new('RateV4Request', :USERID => @options[:login]) do |rate_request|
301
+ packages.each_with_index do |p, id|
302
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
303
+ commercial_type = commercial_type(options)
304
+ default_service = DEFAULT_SERVICE[commercial_type]
305
+ service = options.fetch(:service, default_service).to_sym
306
+
307
+ if commercial_type && service != default_service
308
+ raise ArgumentError, "Commercial #{commercial_type} rates are only provided with the #{default_service.inspect} service."
309
+ end
310
+
311
+ package << XmlNode.new('Service', US_SERVICES[service])
312
+ package << XmlNode.new('FirstClassMailType', FIRST_CLASS_MAIL_TYPES[options[:first_class_mail_type].try(:to_sym)])
313
+ package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
314
+ package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
315
+ package << XmlNode.new('Pounds', 0)
316
+ package << XmlNode.new('Ounces', "%0.1f" % [p.ounces, 1].max)
317
+ package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
318
+ package << XmlNode.new('Size', USPS.size_code_for(p))
319
+ package << XmlNode.new('Width', "%0.2f" % p.inches(:width))
320
+ package << XmlNode.new('Length', "%0.2f" % p.inches(:length))
321
+ package << XmlNode.new('Height', "%0.2f" % p.inches(:height))
322
+ package << XmlNode.new('Girth', "%0.2f" % p.inches(:girth))
323
+ is_machinable = if p.options.has_key?(:machinable)
324
+ p.options[:machinable] ? true : false
325
+ else
326
+ USPS.package_machinable?(p)
327
+ end
328
+ package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
329
+ end
330
+ end
331
+ end
332
+ URI.encode(save_request(request.to_s))
333
+ end
334
+
335
+ # important difference with international rate requests:
336
+ # * services are not given in the request
337
+ # * package sizes are not given in the request
338
+ # * services are returned in the response along with restrictions of size
339
+ # * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
340
+ #
341
+ #
342
+ # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
343
+ # Defaults to :package.
344
+ def build_world_rate_request(packages, destination, options)
345
+ country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name
346
+ request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request|
347
+ packages.each_index do |id|
348
+ p = packages[id]
349
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
350
+ package << XmlNode.new('Pounds', 0)
351
+ package << XmlNode.new('Ounces', [p.ounces, 1].max.ceil) # takes an integer for some reason, must be rounded UP
352
+ package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
353
+ package << XmlNode.new('GXG') do |gxg|
354
+ gxg << XmlNode.new('POBoxFlag', destination.po_box? ? 'Y' : 'N')
355
+ gxg << XmlNode.new('GiftFlag', p.gift? ? 'Y' : 'N')
356
+ end
357
+ value = if p.value && p.value > 0 && p.currency && p.currency != 'USD'
358
+ 0.0
359
+ else
360
+ (p.value || 0) / 100.0
361
+ end
362
+ package << XmlNode.new('ValueOfContents', value)
363
+ package << XmlNode.new('Country') do |node|
364
+ node.cdata = country
365
+ end
366
+ package << XmlNode.new('Container', p.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR')
367
+ package << XmlNode.new('Size', USPS.size_code_for(p))
368
+ package << XmlNode.new('Width', "%0.2f" % [p.inches(:width), 0.01].max)
369
+ package << XmlNode.new('Length', "%0.2f" % [p.inches(:length), 0.01].max)
370
+ package << XmlNode.new('Height', "%0.2f" % [p.inches(:height), 0.01].max)
371
+ package << XmlNode.new('Girth', "%0.2f" % [p.inches(:girth), 0.01].max)
372
+ if commercial_type = commercial_type(options)
373
+ package << XmlNode.new(COMMERCIAL_FLAG_NAME.fetch(commercial_type), 'Y')
374
+ end
375
+ end
376
+ end
377
+ end
378
+ URI.encode(save_request(request.to_s))
379
+ end
380
+
381
+ def parse_rate_response(origin, destination, packages, response, options = {})
382
+ success = true
383
+ message = ''
384
+ rate_hash = {}
385
+
386
+ xml = REXML::Document.new(response)
387
+
388
+ if error = xml.elements['/Error']
389
+ success = false
390
+ message = error.elements['Description'].text
391
+ else
392
+ xml.elements.each('/*/Package') do |package|
393
+ if package.elements['Error']
394
+ success = false
395
+ message = package.get_text('Error/Description').to_s
396
+ break
397
+ end
398
+ end
399
+
400
+ if success
401
+ rate_hash = rates_from_response_node(xml, packages, options)
402
+ unless rate_hash
403
+ success = false
404
+ message = "Unknown root node in XML response: '#{xml.root.name}'"
405
+ end
406
+ end
407
+
408
+ end
409
+
410
+ if success
411
+ rate_estimates = rate_hash.keys.map do |service_name|
412
+ RateEstimate.new(origin, destination, @@name, "USPS #{service_name}",
413
+ :package_rates => rate_hash[service_name][:package_rates],
414
+ :service_code => rate_hash[service_name][:service_code],
415
+ :currency => 'USD')
416
+ end
417
+ rate_estimates.reject! { |e| e.package_count != packages.length }
418
+ rate_estimates = rate_estimates.sort_by(&:total_price)
419
+ end
420
+
421
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
422
+ end
423
+
424
+ def rates_from_response_node(response_node, packages, options = {})
425
+ rate_hash = {}
426
+ return false unless (root_node = response_node.elements['/IntlRateV2Response | /RateV4Response'])
427
+
428
+ commercial_type = commercial_type(options)
429
+ service_node, service_code_node, service_name_node, rate_node = if root_node.name == 'RateV4Response'
430
+ %w(Postage CLASSID MailService) << DOMESTIC_RATE_FIELD[commercial_type]
431
+ else
432
+ %w(Service ID SvcDescription) << INTERNATIONAL_RATE_FIELD[commercial_type]
433
+ end
434
+
435
+ root_node.each_element('Package') do |package_node|
436
+ this_package = packages[package_node.attributes['ID'].to_i]
437
+
438
+ package_node.each_element(service_node) do |service_response_node|
439
+ service_name = service_response_node.get_text(service_name_node).to_s
440
+
441
+ service_name.gsub!(SERVICE_NAME_SUBSTITUTIONS, '')
442
+ service_name.strip!
443
+
444
+ # aggregate specific package rates into a service-centric RateEstimate
445
+ # first package with a given service name will initialize these;
446
+ # later packages with same service will add to them
447
+ this_service = rate_hash[service_name] ||= {}
448
+ this_service[:service_code] ||= service_response_node.attributes[service_code_node]
449
+ package_rates = this_service[:package_rates] ||= []
450
+ this_package_rate = {:package => this_package,
451
+ :rate => Package.cents_from(rate_value(rate_node, service_response_node, commercial_type))}
452
+
453
+ package_rates << this_package_rate if package_valid_for_service(this_package, service_response_node)
454
+ end
455
+ end
456
+ rate_hash
457
+ end
458
+
459
+ def package_valid_for_service(package, service_node)
460
+ return true if service_node.elements['MaxWeight'].nil?
461
+ max_weight = service_node.get_text('MaxWeight').to_s.to_f
462
+ name = service_node.get_text('SvcDescription | MailService').to_s.downcase
463
+
464
+ if name =~ /flat.rate.box/ # domestic or international flat rate box
465
+ # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
466
+ return (package_valid_for_max_dimensions(package,
467
+ :weight => max_weight, # domestic apparently has no weight restriction
468
+ :length => 11.0,
469
+ :width => 8.5,
470
+ :height => 5.5) or
471
+ package_valid_for_max_dimensions(package,
472
+ :weight => max_weight,
473
+ :length => 13.625,
474
+ :width => 11.875,
475
+ :height => 3.375))
476
+ elsif name =~ /flat.rate.envelope/
477
+ return package_valid_for_max_dimensions(package,
478
+ :weight => max_weight,
479
+ :length => 12.5,
480
+ :width => 9.5,
481
+ :height => 0.75)
482
+ elsif service_node.elements['MailService'] # domestic non-flat rates
483
+ return true
484
+ else # international non-flat rates
485
+ # Some sample english that this is required to parse:
486
+ #
487
+ # 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
488
+ # 'Max. length 24", Max. length, height, depth combined 36"'
489
+ #
490
+ sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
491
+ tokens = sentence.downcase.split(/[^\d]*"/).reject(&:empty?)
492
+ max_dimensions = {:weight => max_weight}
493
+ single_axis_values = []
494
+ tokens.each do |token|
495
+ axis_sum = [/length/, /width/, /height/, /depth/].sum { |regex| (token =~ regex) ? 1 : 0 }
496
+ unless axis_sum == 0
497
+ value = token[/\d+$/].to_f
498
+ if axis_sum == 3
499
+ max_dimensions[:length_plus_width_plus_height] = value
500
+ elsif token =~ /girth/ and axis_sum == 1
501
+ max_dimensions[:length_plus_girth] = value
502
+ else
503
+ single_axis_values << value
504
+ end
505
+ end
506
+ end
507
+ single_axis_values.sort!.reverse!
508
+ [:length, :width, :height].each_with_index do |axis, i|
509
+ max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
510
+ end
511
+ package_valid_for_max_dimensions(package, max_dimensions)
512
+ end
513
+ end
514
+
515
+ def package_valid_for_max_dimensions(package, dimensions)
516
+ ((not ([:length, :width, :height].map { |dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f }.include?(false))) and
517
+ (dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
518
+ (dimensions[:length_plus_girth].nil? or
519
+ dimensions[:length_plus_girth].to_f >=
520
+ package.inches(:length) + package.inches(:girth)) and
521
+ (dimensions[:length_plus_width_plus_height].nil? or
522
+ dimensions[:length_plus_width_plus_height].to_f >=
523
+ package.inches(:length) + package.inches(:width) + package.inches(:height)))
524
+ end
525
+
526
+ def parse_tracking_response(response, options)
527
+ actual_delivery_date, status = nil
528
+ xml = REXML::Document.new(response)
529
+ root_node = xml.elements['TrackResponse']
530
+
531
+ success = response_success?(xml)
532
+ message = response_message(xml)
533
+
534
+ if success
535
+ destination = nil
536
+ shipment_events = []
537
+ tracking_details = xml.elements.collect('*/*/TrackDetail') { |e| e }
538
+
539
+ tracking_summary = xml.elements.collect('*/*/TrackSummary') { |e| e }.first
540
+ tracking_details << tracking_summary
541
+
542
+ tracking_number = root_node.elements['TrackInfo'].attributes['ID'].to_s
543
+
544
+ tracking_details.each do |event|
545
+ details = extract_event_details(event.get_text.to_s)
546
+ shipment_events << ShipmentEvent.new(details.description, details.zoneless_time, details.location) if details.location
547
+ end
548
+
549
+ shipment_events = shipment_events.sort_by(&:time)
550
+
551
+ if last_shipment = shipment_events.last
552
+ status = last_shipment.status
553
+ actual_delivery_date = last_shipment.time if last_shipment.delivered?
554
+ end
555
+ end
556
+
557
+ TrackingResponse.new(success, message, Hash.from_xml(response),
558
+ :carrier => @@name,
559
+ :xml => response,
560
+ :request => last_request,
561
+ :shipment_events => shipment_events,
562
+ :destination => destination,
563
+ :tracking_number => tracking_number,
564
+ :status => status,
565
+ :actual_delivery_date => actual_delivery_date
566
+ )
567
+ end
568
+
569
+ def track_summary_node(document)
570
+ document.elements['*/*/TrackSummary']
571
+ end
572
+
573
+ def error_description_node(document)
574
+ STATUS_NODE_PATTERNS.each do |pattern|
575
+ if node = document.elements[pattern]
576
+ return node
577
+ end
578
+ end
579
+ end
580
+
581
+ def response_status_node(document)
582
+ track_summary_node(document) || error_description_node(document)
583
+ end
584
+
585
+ def has_error?(document)
586
+ !!document.elements['Error']
587
+ end
588
+
589
+ def no_record?(document)
590
+ summary_node = track_summary_node(document)
591
+ if summary_node
592
+ summary = summary_node.get_text.to_s
593
+ RESPONSE_ERROR_MESSAGES.detect { |re| summary =~ re }
594
+ summary =~ /There is no record of that mail item/ || summary =~ /This Information has not been included in this Test Server\./
595
+ else
596
+ false
597
+ end
598
+ end
599
+
600
+ def tracking_info_error?(document)
601
+ document.elements['*/TrackInfo/Error']
602
+ end
603
+
604
+ def response_success?(document)
605
+ !(has_error?(document) || no_record?(document) || tracking_info_error?(document))
606
+ end
607
+
608
+ def response_message(document)
609
+ response_node = response_status_node(document)
610
+ response_node.get_text.to_s
611
+ end
612
+
613
+ def commit(action, request, test = false)
614
+ ssl_get(request_url(action, request, test))
615
+ end
616
+
617
+ def request_url(action, request, test)
618
+ scheme = USE_SSL[action] ? 'https://' : 'http://'
619
+ host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
620
+ resource = test ? TEST_RESOURCE : LIVE_RESOURCE
621
+ "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
622
+ end
623
+
624
+ def strip_zip(zip)
625
+ zip.to_s.scan(/\d{5}/).first || zip
626
+ end
627
+
628
+ private
629
+
630
+ def rate_value(rate_node, service_response_node, commercial_type)
631
+ service_response_node.get_text(rate_node).to_s.to_f
632
+ end
633
+
634
+ def commercial_type(options)
635
+ if options[:commercial_plus] == true
636
+ :plus
637
+ elsif options[:commercial_base] == true
638
+ :base
639
+ end
640
+ end
641
+ end
642
+ end