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,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