active_shipping 0.12.4 → 0.12.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_shipping.rb +2 -1
  3. data/lib/active_shipping/shipping/base.rb +2 -2
  4. data/lib/active_shipping/shipping/carrier.rb +16 -13
  5. data/lib/active_shipping/shipping/carriers/benchmark_carrier.rb +3 -4
  6. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +1 -3
  7. data/lib/active_shipping/shipping/carriers/canada_post.rb +33 -44
  8. data/lib/active_shipping/shipping/carriers/canada_post_pws.rb +72 -81
  9. data/lib/active_shipping/shipping/carriers/fedex.rb +118 -109
  10. data/lib/active_shipping/shipping/carriers/kunaki.rb +33 -32
  11. data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +9 -16
  12. data/lib/active_shipping/shipping/carriers/shipwire.rb +36 -35
  13. data/lib/active_shipping/shipping/carriers/stamps.rb +39 -51
  14. data/lib/active_shipping/shipping/carriers/ups.rb +280 -116
  15. data/lib/active_shipping/shipping/carriers/ups.rb.orig +456 -0
  16. data/lib/active_shipping/shipping/carriers/usps.rb +145 -100
  17. data/lib/active_shipping/shipping/carriers/usps.rb.orig +616 -0
  18. data/lib/active_shipping/shipping/errors.rb +1 -1
  19. data/lib/active_shipping/shipping/label_response.rb +25 -0
  20. data/lib/active_shipping/shipping/location.rb +18 -16
  21. data/lib/active_shipping/shipping/package.rb +51 -54
  22. data/lib/active_shipping/shipping/rate_estimate.rb +10 -12
  23. data/lib/active_shipping/shipping/rate_response.rb +3 -7
  24. data/lib/active_shipping/shipping/response.rb +6 -9
  25. data/lib/active_shipping/shipping/shipment_event.rb +2 -4
  26. data/lib/active_shipping/shipping/shipment_packer.rb +32 -17
  27. data/lib/active_shipping/shipping/shipping_response.rb +2 -4
  28. data/lib/active_shipping/shipping/tracking_response.rb +3 -5
  29. data/lib/active_shipping/version.rb +1 -1
  30. data/lib/vendor/quantified/lib/quantified/attribute.rb +79 -80
  31. data/lib/vendor/quantified/lib/quantified/length.rb +5 -5
  32. data/lib/vendor/quantified/lib/quantified/mass.rb +4 -4
  33. data/lib/vendor/quantified/test/length_test.rb +19 -15
  34. data/lib/vendor/quantified/test/mass_test.rb +14 -14
  35. data/lib/vendor/quantified/test/test_helper.rb +1 -2
  36. data/lib/vendor/test_helper.rb +0 -1
  37. data/lib/vendor/xml_node/benchmark/bench_generation.rb +2 -4
  38. data/lib/vendor/xml_node/lib/xml_node.rb +54 -55
  39. data/lib/vendor/xml_node/test/test_generating.rb +23 -28
  40. data/lib/vendor/xml_node/test/test_parsing.rb +5 -8
  41. metadata +6 -25
  42. checksums.yaml.gz.sig +0 -1
  43. data.tar.gz.sig +0 -0
  44. metadata.gz.sig +0 -0
@@ -0,0 +1,456 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ module ActiveMerchant
4
+ module Shipping
5
+ class UPS < Carrier
6
+ self.retry_safe = true
7
+
8
+ cattr_accessor :default_options
9
+ cattr_reader :name
10
+ @@name = "UPS"
11
+
12
+ TEST_URL = 'https://wwwcie.ups.com'
13
+ LIVE_URL = 'https://onlinetools.ups.com'
14
+
15
+ RESOURCES = {
16
+ :rates => 'ups.app/xml/Rate',
17
+ :track => 'ups.app/xml/Track'
18
+ }
19
+
20
+ PICKUP_CODES = HashWithIndifferentAccess.new({
21
+ :daily_pickup => "01",
22
+ :customer_counter => "03",
23
+ :one_time_pickup => "06",
24
+ :on_call_air => "07",
25
+ :suggested_retail_rates => "11",
26
+ :letter_center => "19",
27
+ :air_service_center => "20"
28
+ })
29
+
30
+ CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new({
31
+ :wholesale => "01",
32
+ :occasional => "03",
33
+ :retail => "04"
34
+ })
35
+
36
+ # these are the defaults described in the UPS API docs,
37
+ # but they don't seem to apply them under all circumstances,
38
+ # so we need to take matters into our own hands
39
+ DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash,key|
40
+ hash[key] = case key.to_sym
41
+ when :daily_pickup then :wholesale
42
+ when :customer_counter then :retail
43
+ else
44
+ :occasional
45
+ end
46
+ end
47
+
48
+ DEFAULT_SERVICES = {
49
+ "01" => "UPS Next Day Air",
50
+ "02" => "UPS Second Day Air",
51
+ "03" => "UPS Ground",
52
+ "07" => "UPS Worldwide Express",
53
+ "08" => "UPS Worldwide Expedited",
54
+ "11" => "UPS Standard",
55
+ "12" => "UPS Three-Day Select",
56
+ "13" => "UPS Next Day Air Saver",
57
+ "14" => "UPS Next Day Air Early A.M.",
58
+ "54" => "UPS Worldwide Express Plus",
59
+ "59" => "UPS Second Day Air A.M.",
60
+ "65" => "UPS Saver",
61
+ "82" => "UPS Today Standard",
62
+ "83" => "UPS Today Dedicated Courier",
63
+ "84" => "UPS Today Intercity",
64
+ "85" => "UPS Today Express",
65
+ "86" => "UPS Today Express Saver"
66
+ }
67
+
68
+ CANADA_ORIGIN_SERVICES = {
69
+ "01" => "UPS Express",
70
+ "02" => "UPS Expedited",
71
+ "14" => "UPS Express Early A.M."
72
+ }
73
+
74
+ MEXICO_ORIGIN_SERVICES = {
75
+ "07" => "UPS Express",
76
+ "08" => "UPS Expedited",
77
+ "54" => "UPS Express Plus"
78
+ }
79
+
80
+ EU_ORIGIN_SERVICES = {
81
+ "07" => "UPS Express",
82
+ "08" => "UPS Expedited"
83
+ }
84
+
85
+ OTHER_NON_US_ORIGIN_SERVICES = {
86
+ "07" => "UPS Express"
87
+ }
88
+
89
+ TRACKING_STATUS_CODES = HashWithIndifferentAccess.new({
90
+ 'I' => :in_transit,
91
+ 'D' => :delivered,
92
+ 'X' => :exception,
93
+ 'P' => :pickup,
94
+ 'M' => :manifest_pickup
95
+ })
96
+
97
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
98
+ EU_COUNTRY_CODES = ["GB", "AT", "BE", "BG", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"]
99
+
100
+ US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
101
+
102
+ def requirements
103
+ [:key, :login, :password]
104
+ end
105
+
106
+ def find_rates(origin, destination, packages, options={})
107
+ origin, destination = upsified_location(origin), upsified_location(destination)
108
+ options = @options.merge(options)
109
+ packages = Array(packages)
110
+ access_request = build_access_request
111
+ rate_request = build_rate_request(origin, destination, packages, options)
112
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
113
+ parse_rate_response(origin, destination, packages, response, options)
114
+ end
115
+
116
+ def find_tracking_info(tracking_number, options={})
117
+ options = @options.update(options)
118
+ access_request = build_access_request
119
+ tracking_request = build_tracking_request(tracking_number, options)
120
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
121
+ parse_tracking_response(response, options)
122
+ end
123
+
124
+ protected
125
+
126
+ def upsified_location(location)
127
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
128
+ atts = {:country => location.state}
129
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
130
+ atts[att] = location.send(att)
131
+ end
132
+ Location.new(atts)
133
+ else
134
+ location
135
+ end
136
+ end
137
+
138
+ def build_access_request
139
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
140
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
141
+ access_request << XmlNode.new('UserId', @options[:login])
142
+ access_request << XmlNode.new('Password', @options[:password])
143
+ end
144
+ xml_request.to_s
145
+ end
146
+
147
+ def build_rate_request(origin, destination, packages, options={})
148
+ packages = Array(packages)
149
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
150
+ root_node << XmlNode.new('Request') do |request|
151
+ request << XmlNode.new('RequestAction', 'Rate')
152
+ request << XmlNode.new('RequestOption', 'Shop')
153
+ # not implemented: 'Rate' RequestOption to specify a single service query
154
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
155
+ end
156
+
157
+ pickup_type = options[:pickup_type] || :daily_pickup
158
+
159
+ root_node << XmlNode.new('PickupType') do |pickup_type_node|
160
+ pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
161
+ # not implemented: PickupType/PickupDetails element
162
+ end
163
+ cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
164
+ root_node << XmlNode.new('CustomerClassification') do |cc_node|
165
+ cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
166
+ end
167
+
168
+ root_node << XmlNode.new('Shipment') do |shipment|
169
+ # not implemented: Shipment/Description element
170
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
171
+ shipment << build_location_node('ShipTo', destination, options)
172
+ if options[:shipper] and options[:shipper] != origin
173
+ shipment << build_location_node('ShipFrom', origin, options)
174
+ end
175
+
176
+ # not implemented: * Shipment/ShipmentWeight element
177
+ # * Shipment/ReferenceNumber element
178
+ # * Shipment/Service element
179
+ # * Shipment/PickupDate element
180
+ # * Shipment/ScheduledDeliveryDate element
181
+ # * Shipment/ScheduledDeliveryTime element
182
+ # * Shipment/AlternateDeliveryTime element
183
+ # * Shipment/DocumentsOnly element
184
+
185
+ packages.each do |package|
186
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
187
+
188
+ shipment << XmlNode.new("Package") do |package_node|
189
+
190
+ # not implemented: * Shipment/Package/PackagingType element
191
+ # * Shipment/Package/Description element
192
+
193
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
194
+ packaging_type << XmlNode.new("Code", '02')
195
+ end
196
+
197
+ package_node << XmlNode.new("Dimensions") do |dimensions|
198
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
199
+ units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
200
+ end
201
+ [:length,:width,:height].each do |axis|
202
+ value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
203
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
204
+ end
205
+ end
206
+
207
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
208
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
209
+ units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
210
+ end
211
+
212
+ value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
213
+ package_weight << XmlNode.new("Weight", [value,0.1].max)
214
+ end
215
+
216
+ # not implemented: * Shipment/Package/LargePackageIndicator element
217
+ # * Shipment/Package/ReferenceNumber element
218
+ # * Shipment/Package/PackageServiceOptions element
219
+ # * Shipment/Package/AdditionalHandling element
220
+ end
221
+
222
+ end
223
+
224
+ # not implemented: * Shipment/ShipmentServiceOptions element
225
+ if options[:origin_account]
226
+ shipment << XmlNode.new("RateInformation") do |rate_info_node|
227
+ rate_info_node << XmlNode.new("NegotiatedRatesIndicator")
228
+ end
229
+ end
230
+
231
+ end
232
+
233
+ end
234
+ xml_request.to_s
235
+ end
236
+
237
+ def build_tracking_request(tracking_number, options={})
238
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
239
+ root_node << XmlNode.new('Request') do |request|
240
+ request << XmlNode.new('RequestAction', 'Track')
241
+ request << XmlNode.new('RequestOption', '1')
242
+ end
243
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
244
+ end
245
+ xml_request.to_s
246
+ end
247
+
248
+ def build_location_node(name,location,options={})
249
+ # not implemented: * Shipment/Shipper/Name element
250
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
251
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
252
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
253
+ location_node = XmlNode.new(name) do |location_node|
254
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
255
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
256
+
257
+ if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
258
+ location_node << XmlNode.new('ShipperNumber', origin_account)
259
+ elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
260
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
261
+ end
262
+
263
+ location_node << XmlNode.new('Address') do |address|
264
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
265
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
266
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
267
+ address << XmlNode.new("City", location.city) unless location.city.blank?
268
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
269
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
270
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
271
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
272
+ 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
273
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
274
+ end
275
+ end
276
+ end
277
+
278
+ def parse_rate_response(origin, destination, packages, response, options={})
279
+ rates = []
280
+ File.open('/Users/timmy/code/active_shipping/foo.txt', 'w') {|f| f.write(response) }
281
+ xml = REXML::Document.new(response)
282
+ success = response_success?(xml)
283
+ message = response_message(xml)
284
+
285
+ if success
286
+ rate_estimates = []
287
+
288
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
289
+ service_code = rated_shipment.get_text('Service/Code').to_s
290
+ days_to_delivery = rated_shipment.get_text('GuaranteedDaysToDelivery').to_s.to_i
291
+ delivery_date = days_to_delivery >= 1 ? days_to_delivery.days.from_now.strftime("%Y-%m-%d") : nil
292
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
293
+ service_name_for(origin, service_code),
294
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
295
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
296
+ :service_code => service_code,
297
+ :packages => packages,
298
+ :delivery_range => [delivery_date],
299
+ :negotiated_rate => rated_shipment.get_text('NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue').to_s.to_f)
300
+ end
301
+ end
302
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
303
+ end
304
+
305
+ def parse_tracking_response(response, options={})
306
+ xml = REXML::Document.new(response)
307
+ success = response_success?(xml)
308
+ message = response_message(xml)
309
+
310
+ if success
311
+ tracking_number, origin, destination, status_code, status_description = nil
312
+ delivered, exception = false
313
+ exception_event = nil
314
+ shipment_events = []
315
+ status = {}
316
+ scheduled_delivery_date = nil
317
+
318
+ first_shipment = xml.elements['/*/Shipment']
319
+ first_package = first_shipment.elements['Package']
320
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
321
+
322
+ # Build status hash
323
+ status_node = first_package.elements['Activity/Status/StatusType']
324
+ status_code = status_node.get_text('Code').to_s
325
+ status_description = status_node.get_text('Description').to_s
326
+ status = TRACKING_STATUS_CODES[status_code]
327
+
328
+ if status_description =~ /out.*delivery/i
329
+ status = :out_for_delivery
330
+ end
331
+
332
+ origin, destination = %w{Shipper ShipTo}.map do |location|
333
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
334
+ end
335
+
336
+ # Get scheduled delivery date
337
+ unless status == :delivered
338
+ scheduled_delivery_date = parse_ups_datetime({
339
+ :date => first_shipment.get_text('ScheduledDeliveryDate'),
340
+ :time => nil
341
+ })
342
+ end
343
+
344
+ activities = first_package.get_elements('Activity')
345
+ unless activities.empty?
346
+ shipment_events = activities.map do |activity|
347
+ description = activity.get_text('Status/StatusType/Description').to_s
348
+ zoneless_time = if (time = activity.get_text('Time')) &&
349
+ (date = activity.get_text('Date'))
350
+ time, date = time.to_s, date.to_s
351
+ hour, minute, second = time.scan(/\d{2}/)
352
+ year, month, day = date[0..3], date[4..5], date[6..7]
353
+ Time.utc(year, month, day, hour, minute, second)
354
+ end
355
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
356
+ ShipmentEvent.new(description, zoneless_time, location)
357
+ end
358
+
359
+ shipment_events = shipment_events.sort_by(&:time)
360
+
361
+ # UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
362
+ # event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
363
+ # This adds an origin event to the shipment activity in such cases.
364
+ if origin && !(shipment_events.count == 1 && status == :delivered)
365
+ first_event = shipment_events[0]
366
+ same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
367
+ same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
368
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
369
+ if same_country and same_or_blank_city
370
+ shipment_events[0] = origin_event
371
+ else
372
+ shipment_events.unshift(origin_event)
373
+ end
374
+ end
375
+
376
+ # Has the shipment been delivered?
377
+ if status == :delivered
378
+ if !destination
379
+ destination = shipment_events[-1].location
380
+ end
381
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
382
+ end
383
+ end
384
+
385
+ end
386
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
387
+ :carrier => @@name,
388
+ :xml => response,
389
+ :request => last_request,
390
+ :status => status,
391
+ :status_code => status_code,
392
+ :status_description => status_description,
393
+ :scheduled_delivery_date => scheduled_delivery_date,
394
+ :shipment_events => shipment_events,
395
+ :delivered => delivered,
396
+ :exception => exception,
397
+ :exception_event => exception_event,
398
+ :origin => origin,
399
+ :destination => destination,
400
+ :tracking_number => tracking_number)
401
+ end
402
+
403
+ def location_from_address_node(address)
404
+ return nil unless address
405
+ Location.new(
406
+ :country => node_text_or_nil(address.elements['CountryCode']),
407
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
408
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
409
+ :city => node_text_or_nil(address.elements['City']),
410
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
411
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
412
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
413
+ )
414
+ end
415
+
416
+ def parse_ups_datetime(options = {})
417
+ time, date = options[:time].to_s, options[:date].to_s
418
+ if time.nil?
419
+ hour, minute, second = 0
420
+ else
421
+ hour, minute, second = time.scan(/\d{2}/)
422
+ end
423
+ year, month, day = date[0..3], date[4..5], date[6..7]
424
+
425
+ Time.utc(year, month, day, hour, minute, second)
426
+ end
427
+
428
+ def response_success?(xml)
429
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
430
+ end
431
+
432
+ def response_message(xml)
433
+ xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
434
+ end
435
+
436
+ def commit(action, request, test = false)
437
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
438
+ end
439
+
440
+
441
+ def service_name_for(origin, code)
442
+ origin = origin.country_code(:alpha2)
443
+
444
+ name = case origin
445
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
446
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
447
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
448
+ end
449
+
450
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
451
+ name ||= DEFAULT_SERVICES[code]
452
+ end
453
+
454
+ end
455
+ end
456
+ end