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