active_shipping 0.12.6 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
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,648 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ module ActiveShipping
4
+ class UPS < Carrier
5
+ self.retry_safe = true
6
+
7
+ cattr_accessor :default_options
8
+ cattr_reader :name
9
+ @@name = "UPS"
10
+
11
+ TEST_URL = 'https://wwwcie.ups.com'
12
+ LIVE_URL = 'https://onlinetools.ups.com'
13
+
14
+ RESOURCES = {
15
+ :rates => 'ups.app/xml/Rate',
16
+ :track => 'ups.app/xml/Track',
17
+ :ship_confirm => 'ups.app/xml/ShipConfirm',
18
+ :ship_accept => 'ups.app/xml/ShipAccept'
19
+ }
20
+
21
+ PICKUP_CODES = HashWithIndifferentAccess.new(
22
+ :daily_pickup => "01",
23
+ :customer_counter => "03",
24
+ :one_time_pickup => "06",
25
+ :on_call_air => "07",
26
+ :suggested_retail_rates => "11",
27
+ :letter_center => "19",
28
+ :air_service_center => "20"
29
+ )
30
+
31
+ CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new(
32
+ :wholesale => "01",
33
+ :occasional => "03",
34
+ :retail => "04"
35
+ )
36
+
37
+ # these are the defaults described in the UPS API docs,
38
+ # but they don't seem to apply them under all circumstances,
39
+ # so we need to take matters into our own hands
40
+ DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash, key|
41
+ hash[key] = case key.to_sym
42
+ when :daily_pickup then :wholesale
43
+ when :customer_counter then :retail
44
+ else
45
+ :occasional
46
+ end
47
+ end
48
+
49
+ DEFAULT_SERVICES = {
50
+ "01" => "UPS Next Day Air",
51
+ "02" => "UPS Second Day Air",
52
+ "03" => "UPS Ground",
53
+ "07" => "UPS Worldwide Express",
54
+ "08" => "UPS Worldwide Expedited",
55
+ "11" => "UPS Standard",
56
+ "12" => "UPS Three-Day Select",
57
+ "13" => "UPS Next Day Air Saver",
58
+ "14" => "UPS Next Day Air Early A.M.",
59
+ "54" => "UPS Worldwide Express Plus",
60
+ "59" => "UPS Second Day Air A.M.",
61
+ "65" => "UPS Saver",
62
+ "82" => "UPS Today Standard",
63
+ "83" => "UPS Today Dedicated Courier",
64
+ "84" => "UPS Today Intercity",
65
+ "85" => "UPS Today Express",
66
+ "86" => "UPS Today Express Saver"
67
+ }
68
+
69
+ CANADA_ORIGIN_SERVICES = {
70
+ "01" => "UPS Express",
71
+ "02" => "UPS Expedited",
72
+ "14" => "UPS Express Early A.M."
73
+ }
74
+
75
+ MEXICO_ORIGIN_SERVICES = {
76
+ "07" => "UPS Express",
77
+ "08" => "UPS Expedited",
78
+ "54" => "UPS Express Plus"
79
+ }
80
+
81
+ EU_ORIGIN_SERVICES = {
82
+ "07" => "UPS Express",
83
+ "08" => "UPS Expedited"
84
+ }
85
+
86
+ OTHER_NON_US_ORIGIN_SERVICES = {
87
+ "07" => "UPS Express"
88
+ }
89
+
90
+ TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
91
+ 'I' => :in_transit,
92
+ 'D' => :delivered,
93
+ 'X' => :exception,
94
+ 'P' => :pickup,
95
+ 'M' => :manifest_pickup
96
+ )
97
+
98
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
99
+ EU_COUNTRY_CODES = %w(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)
100
+
101
+ US_TERRITORIES_TREATED_AS_COUNTRIES = %w(AS FM GU MH MP PW PR VI)
102
+
103
+ IMPERIAL_COUNTRIES = %w(US LR MM)
104
+
105
+ def requirements
106
+ [:key, :login, :password]
107
+ end
108
+
109
+ def find_rates(origin, destination, packages, options = {})
110
+ origin, destination = upsified_location(origin), upsified_location(destination)
111
+ options = @options.merge(options)
112
+ packages = Array(packages)
113
+ access_request = build_access_request
114
+ rate_request = build_rate_request(origin, destination, packages, options)
115
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
116
+ parse_rate_response(origin, destination, packages, response, options)
117
+ end
118
+
119
+ def find_tracking_info(tracking_number, options = {})
120
+ options = @options.update(options)
121
+ access_request = build_access_request
122
+ tracking_request = build_tracking_request(tracking_number, options)
123
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
124
+ parse_tracking_response(response, options)
125
+ end
126
+
127
+ def create_shipment(origin, destination, packages, options = {})
128
+ options = @options.merge(options)
129
+ packages = Array(packages)
130
+ access_request = build_access_request
131
+
132
+ begin
133
+
134
+ # STEP 1: Confirm. Validation step, important for verifying price.
135
+ confirm_request = build_shipment_request(origin, destination, packages, options)
136
+ logger.debug(confirm_request) if logger
137
+
138
+ confirm_response = commit(:ship_confirm, save_request(access_request + confirm_request), (options[:test] || false))
139
+ logger.debug(confirm_response) if logger
140
+
141
+ # ... now, get the digest, it's needed to get the label. In theory,
142
+ # one could make decisions based on the price or some such to avoid
143
+ # surprises. This also has *no* error handling yet.
144
+ xml = parse_ship_confirm(confirm_response)
145
+ success = response_success?(xml)
146
+ message = response_message(xml)
147
+ digest = response_digest(xml)
148
+ raise message unless success
149
+
150
+ # STEP 2: Accept. Use shipment digest in first response to get the actual label.
151
+ accept_request = build_accept_request(digest, options)
152
+ logger.debug(accept_request) if logger
153
+
154
+ accept_response = commit(:ship_accept, save_request(access_request + accept_request), (options[:test] || false))
155
+ logger.debug(accept_response) if logger
156
+
157
+ # ...finally, build a map from the response that contains
158
+ # the label data and tracking information.
159
+ parse_ship_accept(accept_response)
160
+
161
+ rescue RuntimeError => e
162
+ raise "Could not obtain shipping label. #{e.message}."
163
+
164
+ end
165
+ end
166
+
167
+ protected
168
+
169
+ def upsified_location(location)
170
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
171
+ atts = {:country => location.state}
172
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
173
+ atts[att] = location.send(att)
174
+ end
175
+ Location.new(atts)
176
+ else
177
+ location
178
+ end
179
+ end
180
+
181
+ def build_access_request
182
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
183
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
184
+ access_request << XmlNode.new('UserId', @options[:login])
185
+ access_request << XmlNode.new('Password', @options[:password])
186
+ end
187
+ xml_request.to_s
188
+ end
189
+
190
+ def build_rate_request(origin, destination, packages, options = {})
191
+ packages = Array(packages)
192
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
193
+ root_node << XmlNode.new('Request') do |request|
194
+ request << XmlNode.new('RequestAction', 'Rate')
195
+ request << XmlNode.new('RequestOption', 'Shop')
196
+ # not implemented: 'Rate' RequestOption to specify a single service query
197
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
198
+ end
199
+
200
+ pickup_type = options[:pickup_type] || :daily_pickup
201
+
202
+ root_node << XmlNode.new('PickupType') do |pickup_type_node|
203
+ pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
204
+ # not implemented: PickupType/PickupDetails element
205
+ end
206
+ cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
207
+ root_node << XmlNode.new('CustomerClassification') do |cc_node|
208
+ cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
209
+ end
210
+
211
+ root_node << XmlNode.new('Shipment') do |shipment|
212
+ # not implemented: Shipment/Description element
213
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
214
+ shipment << build_location_node('ShipTo', destination, options)
215
+ if options[:shipper] and options[:shipper] != origin
216
+ shipment << build_location_node('ShipFrom', origin, options)
217
+ end
218
+
219
+ # not implemented: * Shipment/ShipmentWeight element
220
+ # * Shipment/ReferenceNumber element
221
+ # * Shipment/Service element
222
+ # * Shipment/PickupDate element
223
+ # * Shipment/ScheduledDeliveryDate element
224
+ # * Shipment/ScheduledDeliveryTime element
225
+ # * Shipment/AlternateDeliveryTime element
226
+ # * Shipment/DocumentsOnly element
227
+
228
+ packages.each do |package|
229
+ options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
230
+ shipment << build_package_node(package, options)
231
+ end
232
+
233
+ # not implemented: * Shipment/ShipmentServiceOptions element
234
+ if options[:origin_account]
235
+ shipment << XmlNode.new("RateInformation") do |rate_info_node|
236
+ rate_info_node << XmlNode.new("NegotiatedRatesIndicator")
237
+ end
238
+ end
239
+ end
240
+ end
241
+ xml_request.to_s
242
+ end
243
+
244
+ # Build XML node to request a shipping label for the given packages.
245
+ #
246
+ # options:
247
+ # * origin_account: who will pay for the shipping label
248
+ # * customer_context: a "guid like substance" -- according to UPS
249
+ # * shipper: who is sending the package and where it should be returned
250
+ # if it is undeliverable.
251
+ # * ship_from: where the package is picked up.
252
+ # * service_code: default to '14'
253
+ # * service_descriptor: default to 'Next Day Air Early AM'
254
+ # * saturday_delivery: any truthy value causes this element to exist
255
+ # * optional_processing: 'validate' (blank) or 'nonvalidate' or blank
256
+ #
257
+ def build_shipment_request(origin, destination, packages, options = {})
258
+ # There are a lot of unimplemented elements, documenting all of them
259
+ # wouldprobably be unhelpful.
260
+
261
+ xml_request = XmlNode.new('ShipmentConfirmRequest') do |root_node|
262
+ root_node << XmlNode.new('Request') do |request|
263
+ # Required element and the text must be "ShipConfirm"
264
+ request << XmlNode.new('RequestAction', 'ShipConfirm')
265
+ # Required element cotnrols level of address validation.
266
+ request << XmlNode.new('RequestOption', options[:optional_processing] || 'validate')
267
+ # Optional element to identify transactions between client and server.
268
+ if options[:customer_context]
269
+ request << XmlNode.new('TransactionReference') do |refer|
270
+ refer << XmlNode.new('CustomerContext', options[:customer_context])
271
+ end
272
+ end
273
+ end
274
+ root_node << XmlNode.new('Shipment') do |shipment|
275
+ # Required element.
276
+ shipment << XmlNode.new('Service') do |service|
277
+ service << XmlNode.new('Code', options[:service_code] || '14')
278
+ service << XmlNode.new('Description', options[:service_description] || 'Next Day Air Early AM')
279
+ end
280
+ # Required element. The delivery destination.
281
+ shipment << build_location_node('ShipTo', destination, {})
282
+ # Required element. The company whose account is responsible for the label(s).
283
+ shipment << build_location_node('Shipper', options[:shipper] || origin, {})
284
+ # Required if pickup is different different from shipper's address.
285
+ if options[:ship_from]
286
+ shipment << build_location_node('ShipFrom', options[:ship_from], {})
287
+ end
288
+ # Optional.
289
+ if options[:saturday_delivery]
290
+ shipment << XmlNode.new('ShipmentServiceOptions') do |opts|
291
+ opts << XmlNode.new('SaturdayDelivery')
292
+ end
293
+ end
294
+ # Optional.
295
+ if options[:origin_account]
296
+ shipment << XmlNode.new('RateInformation') do |rate|
297
+ rate << XmlNode.new('NegotiatedRatesIndicator')
298
+ end
299
+ end
300
+ # Optional.
301
+ if options[:shipment] && options[:shipment][:reference_number]
302
+ shipment << XmlNode.new("ReferenceNumber") do |ref_node|
303
+ ref_node << XmlNode.new("Code", options[:shipment][:reference_number][:code] || "")
304
+ ref_node << XmlNode.new("Value", options[:shipment][:reference_number][:value])
305
+ end
306
+ end
307
+ # Conditionally required. Either this element or an ItemizedPaymentInformation
308
+ # is needed. However, only PaymentInformation is not implemented.
309
+ shipment << XmlNode.new('PaymentInformation') do |payment|
310
+ payment << XmlNode.new('Prepaid') do |prepay|
311
+ prepay << XmlNode.new('BillShipper') do |bill|
312
+ bill << XmlNode.new('AccountNumber', options[:origin_account])
313
+ end
314
+ end
315
+ end
316
+ # A request may specify multiple packages.
317
+ options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
318
+ packages.each do |package|
319
+ shipment << build_package_node(package, options)
320
+ end
321
+ end
322
+ # I don't know all of the options that UPS supports for labels
323
+ # so I'm going with something very simple for now.
324
+ root_node << XmlNode.new('LabelSpecification') do |specification|
325
+ specification << XmlNode.new('LabelPrintMethod') do |print_method|
326
+ print_method << XmlNode.new('Code', 'GIF')
327
+ end
328
+ specification << XmlNode.new('HTTPUserAgent', 'Mozilla/4.5') # hmmm
329
+ specification << XmlNode.new('LabelImageFormat', 'GIF') do |image_format|
330
+ image_format << XmlNode.new('Code', 'GIF')
331
+ end
332
+ end
333
+ end
334
+ xml_request.to_s
335
+ end
336
+
337
+ def build_accept_request(digest, options = {})
338
+ xml_request = XmlNode.new('ShipmentAcceptRequest') do |root_node|
339
+ root_node << XmlNode.new('Request') do |request|
340
+ request << XmlNode.new('RequestAction', 'ShipAccept')
341
+ end
342
+ root_node << XmlNode.new('ShipmentDigest', digest)
343
+ end
344
+ xml_request.to_s
345
+ end
346
+
347
+ def build_tracking_request(tracking_number, options = {})
348
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
349
+ root_node << XmlNode.new('Request') do |request|
350
+ request << XmlNode.new('RequestAction', 'Track')
351
+ request << XmlNode.new('RequestOption', '1')
352
+ end
353
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
354
+ end
355
+ xml_request.to_s
356
+ end
357
+
358
+ def build_location_node(name, location, options = {})
359
+ # not implemented: * Shipment/Shipper/Name element
360
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
361
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
362
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
363
+ XmlNode.new(name) do |location_node|
364
+ # You must specify the shipper name when creating labels.
365
+ if shipper_name = (options[:origin_name] || @options[:origin_name])
366
+ location_node << XmlNode.new('Name', shipper_name)
367
+ end
368
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/, '')) unless location.phone.blank?
369
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/, '')) unless location.fax.blank?
370
+
371
+ if name == 'Shipper' and (origin_account = options[:origin_account] || @options[:origin_account])
372
+ location_node << XmlNode.new('ShipperNumber', origin_account)
373
+ elsif name == 'ShipTo' and (destination_account = options[:destination_account] || @options[:destination_account])
374
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
375
+ end
376
+
377
+ if name = location.company_name || location.name
378
+ location_node << XmlNode.new('CompanyName', name)
379
+ end
380
+
381
+ if phone = location.phone
382
+ location_node << XmlNode.new('PhoneNumber', phone)
383
+ end
384
+
385
+ if attn = location.name
386
+ location_node << XmlNode.new('AttentionName', attn)
387
+ end
388
+
389
+ location_node << XmlNode.new('Address') do |address|
390
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
391
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
392
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
393
+ address << XmlNode.new("City", location.city) unless location.city.blank?
394
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
395
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
396
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
397
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
398
+ 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
399
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
400
+ end
401
+ end
402
+ end
403
+
404
+ def build_package_node(package, options = {})
405
+ XmlNode.new("Package") do |package_node|
406
+
407
+ # not implemented: * Shipment/Package/PackagingType element
408
+ # * Shipment/Package/Description element
409
+
410
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
411
+ packaging_type << XmlNode.new("Code", '02')
412
+ end
413
+
414
+ package_node << XmlNode.new("Dimensions") do |dimensions|
415
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
416
+ units << XmlNode.new("Code", options[:imperial] ? 'IN' : 'CM')
417
+ end
418
+ [:length, :width, :height].each do |axis|
419
+ value = ((options[:imperial] ? package.inches(axis) : package.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
420
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value, 0.1].max)
421
+ end
422
+ end
423
+
424
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
425
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
426
+ units << XmlNode.new("Code", options[:imperial] ? 'LBS' : 'KGS')
427
+ end
428
+
429
+ value = ((options[:imperial] ? package.lbs : package.kgs).to_f * 1000).round / 1000.0 # 3 decimals
430
+ package_weight << XmlNode.new("Weight", [value, 0.1].max)
431
+ end
432
+
433
+ if options[:package] && options[:package][:reference_number]
434
+ package_node << XmlNode.new("ReferenceNumber") do |ref_node|
435
+ ref_node << XmlNode.new("Code", options[:package][:reference_number][:code] || "")
436
+ ref_node << XmlNode.new("Value", options[:package][:reference_number][:value])
437
+ end
438
+ end
439
+
440
+ package_node
441
+
442
+ # not implemented: * Shipment/Package/LargePackageIndicator element
443
+ # * Shipment/Package/PackageServiceOptions element
444
+ # * Shipment/Package/AdditionalHandling element
445
+ end
446
+ end
447
+
448
+ def parse_rate_response(origin, destination, packages, response, options = {})
449
+ xml = REXML::Document.new(response)
450
+ success = response_success?(xml)
451
+ message = response_message(xml)
452
+
453
+ if success
454
+ rate_estimates = []
455
+
456
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
457
+ service_code = rated_shipment.get_text('Service/Code').to_s
458
+ days_to_delivery = rated_shipment.get_text('GuaranteedDaysToDelivery').to_s.to_i
459
+ days_to_delivery = nil if days_to_delivery == 0
460
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
461
+ service_name_for(origin, service_code),
462
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
463
+ :insurance_price => rated_shipment.get_text('ServiceOptionsCharges/MonetaryValue').to_s.to_f,
464
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
465
+ :service_code => service_code,
466
+ :packages => packages,
467
+ :delivery_range => [timestamp_from_business_day(days_to_delivery)],
468
+ :negotiated_rate => rated_shipment.get_text('NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue').to_s.to_f)
469
+ end
470
+ end
471
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
472
+ end
473
+
474
+ def parse_tracking_response(response, options = {})
475
+ xml = REXML::Document.new(response)
476
+ success = response_success?(xml)
477
+ message = response_message(xml)
478
+
479
+ if success
480
+ delivery_signature = nil
481
+ exception_event, scheduled_delivery_date, actual_delivery_date = nil
482
+ delivered, exception = false
483
+ shipment_events = []
484
+
485
+ first_shipment = xml.elements['/*/Shipment']
486
+ first_package = first_shipment.elements['Package']
487
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
488
+
489
+ # Build status hash
490
+ status_nodes = first_package.elements.to_a('Activity/Status/StatusType')
491
+
492
+ # Prefer a delivery node
493
+ status_node = status_nodes.detect { |x| x.get_text('Code').to_s == 'D' }
494
+ status_node ||= status_nodes.first
495
+
496
+ status_code = status_node.get_text('Code').to_s
497
+ status_description = status_node.get_text('Description').to_s
498
+ status = TRACKING_STATUS_CODES[status_code]
499
+
500
+ if status_description =~ /out.*delivery/i
501
+ status = :out_for_delivery
502
+ end
503
+
504
+ origin, destination = %w(Shipper ShipTo).map do |location|
505
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
506
+ end
507
+
508
+ # Get scheduled delivery date
509
+ unless status == :delivered
510
+ scheduled_delivery_date = parse_ups_datetime(
511
+ :date => first_shipment.get_text('ScheduledDeliveryDate'),
512
+ :time => nil
513
+ )
514
+ end
515
+
516
+ activities = first_package.get_elements('Activity')
517
+ unless activities.empty?
518
+ shipment_events = activities.map do |activity|
519
+ description = activity.get_text('Status/StatusType/Description').to_s
520
+ zoneless_time = parse_ups_datetime(:time => activity.get_text('Time'), :date => activity.get_text('Date'))
521
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
522
+ ShipmentEvent.new(description, zoneless_time, location)
523
+ end
524
+
525
+ shipment_events = shipment_events.sort_by(&:time)
526
+
527
+ # UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
528
+ # event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
529
+ # This adds an origin event to the shipment activity in such cases.
530
+ if origin && !(shipment_events.count == 1 && status == :delivered)
531
+ first_event = shipment_events[0]
532
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
533
+
534
+ if within_same_area?(origin, first_event.location)
535
+ shipment_events[0] = origin_event
536
+ else
537
+ shipment_events.unshift(origin_event)
538
+ end
539
+ end
540
+
541
+ # Has the shipment been delivered?
542
+ if status == :delivered
543
+ delivered_activity = activities.first
544
+ delivery_signature = delivered_activity.get_text('ActivityLocation/SignedForByName').to_s
545
+ if delivered_activity.get_text('Status/StatusType/Code') == 'D'
546
+ actual_delivery_date = parse_ups_datetime(:date => delivered_activity.get_text('Date'), :time => delivered_activity.get_text('Time'))
547
+ end
548
+ unless destination
549
+ destination = shipment_events[-1].location
550
+ end
551
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
552
+ end
553
+ end
554
+
555
+ end
556
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
557
+ :carrier => @@name,
558
+ :xml => response,
559
+ :request => last_request,
560
+ :status => status,
561
+ :status_code => status_code,
562
+ :status_description => status_description,
563
+ :delivery_signature => delivery_signature,
564
+ :scheduled_delivery_date => scheduled_delivery_date,
565
+ :actual_delivery_date => actual_delivery_date,
566
+ :shipment_events => shipment_events,
567
+ :delivered => delivered,
568
+ :exception => exception,
569
+ :exception_event => exception_event,
570
+ :origin => origin,
571
+ :destination => destination,
572
+ :tracking_number => tracking_number)
573
+ end
574
+
575
+ def location_from_address_node(address)
576
+ return nil unless address
577
+ Location.new(
578
+ :country => node_text_or_nil(address.elements['CountryCode']),
579
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
580
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
581
+ :city => node_text_or_nil(address.elements['City']),
582
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
583
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
584
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
585
+ )
586
+ end
587
+
588
+ def parse_ups_datetime(options = {})
589
+ time, date = options[:time].to_s, options[:date].to_s
590
+ if time.nil?
591
+ hour, minute, second = 0
592
+ else
593
+ hour, minute, second = time.scan(/\d{2}/)
594
+ end
595
+ year, month, day = date[0..3], date[4..5], date[6..7]
596
+
597
+ Time.utc(year, month, day, hour, minute, second)
598
+ end
599
+
600
+ def response_success?(xml)
601
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
602
+ end
603
+
604
+ def response_message(xml)
605
+ xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
606
+ end
607
+
608
+ def response_digest(xml)
609
+ xml.get_text('/*/ShipmentDigest').to_s
610
+ end
611
+
612
+ def parse_ship_confirm(response)
613
+ REXML::Document.new(response)
614
+ end
615
+
616
+ def parse_ship_accept(response)
617
+ xml = REXML::Document.new(response)
618
+ success = response_success?(xml)
619
+ message = response_message(xml)
620
+
621
+ LabelResponse.new(success, message, Hash.from_xml(response).values.first)
622
+ end
623
+
624
+ def commit(action, request, test = false)
625
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
626
+ end
627
+
628
+ def within_same_area?(origin, location)
629
+ return false unless location
630
+ matching_country_codes = origin.country_code(:alpha2) == location.country_code(:alpha2)
631
+ matching_or_blank_city = location.city.blank? || location.city == origin.city
632
+ matching_country_codes && matching_or_blank_city
633
+ end
634
+
635
+ def service_name_for(origin, code)
636
+ origin = origin.country_code(:alpha2)
637
+
638
+ name = case origin
639
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
640
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
641
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
642
+ end
643
+
644
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
645
+ name || DEFAULT_SERVICES[code]
646
+ end
647
+ end
648
+ end