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,579 @@
1
+ # FedEx module by Jimmy Baker
2
+ # http://github.com/jimmyebaker
3
+
4
+ require 'date'
5
+ module ActiveShipping
6
+ # :key is your developer API key
7
+ # :password is your API password
8
+ # :account is your FedEx account number
9
+ # :login is your meter number
10
+ class FedEx < Carrier
11
+ self.retry_safe = true
12
+
13
+ cattr_reader :name
14
+ @@name = "FedEx"
15
+
16
+ TEST_URL = 'https://gatewaybeta.fedex.com:443/xml'
17
+ LIVE_URL = 'https://gateway.fedex.com:443/xml'
18
+
19
+ CARRIER_CODES = {
20
+ "fedex_ground" => "FDXG",
21
+ "fedex_express" => "FDXE"
22
+ }
23
+
24
+ DELIVERY_ADDRESS_NODE_NAMES = %w(DestinationAddress ActualDeliveryAddress)
25
+ SHIPPER_ADDRESS_NODE_NAMES = %w(ShipperAddress)
26
+
27
+ SERVICE_TYPES = {
28
+ "PRIORITY_OVERNIGHT" => "FedEx Priority Overnight",
29
+ "PRIORITY_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx Priority Overnight Saturday Delivery",
30
+ "FEDEX_2_DAY" => "FedEx 2 Day",
31
+ "FEDEX_2_DAY_SATURDAY_DELIVERY" => "FedEx 2 Day Saturday Delivery",
32
+ "STANDARD_OVERNIGHT" => "FedEx Standard Overnight",
33
+ "FIRST_OVERNIGHT" => "FedEx First Overnight",
34
+ "FIRST_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx First Overnight Saturday Delivery",
35
+ "FEDEX_EXPRESS_SAVER" => "FedEx Express Saver",
36
+ "FEDEX_1_DAY_FREIGHT" => "FedEx 1 Day Freight",
37
+ "FEDEX_1_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 1 Day Freight Saturday Delivery",
38
+ "FEDEX_2_DAY_FREIGHT" => "FedEx 2 Day Freight",
39
+ "FEDEX_2_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 2 Day Freight Saturday Delivery",
40
+ "FEDEX_3_DAY_FREIGHT" => "FedEx 3 Day Freight",
41
+ "FEDEX_3_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 3 Day Freight Saturday Delivery",
42
+ "INTERNATIONAL_PRIORITY" => "FedEx International Priority",
43
+ "INTERNATIONAL_PRIORITY_SATURDAY_DELIVERY" => "FedEx International Priority Saturday Delivery",
44
+ "INTERNATIONAL_ECONOMY" => "FedEx International Economy",
45
+ "INTERNATIONAL_FIRST" => "FedEx International First",
46
+ "INTERNATIONAL_PRIORITY_FREIGHT" => "FedEx International Priority Freight",
47
+ "INTERNATIONAL_ECONOMY_FREIGHT" => "FedEx International Economy Freight",
48
+ "GROUND_HOME_DELIVERY" => "FedEx Ground Home Delivery",
49
+ "FEDEX_GROUND" => "FedEx Ground",
50
+ "INTERNATIONAL_GROUND" => "FedEx International Ground",
51
+ "SMART_POST" => "FedEx SmartPost",
52
+ "FEDEX_FREIGHT_PRIORITY" => "FedEx Freight Priority",
53
+ "FEDEX_FREIGHT_ECONOMY" => "FedEx Freight Economy"
54
+ }
55
+
56
+ PACKAGE_TYPES = {
57
+ "fedex_envelope" => "FEDEX_ENVELOPE",
58
+ "fedex_pak" => "FEDEX_PAK",
59
+ "fedex_box" => "FEDEX_BOX",
60
+ "fedex_tube" => "FEDEX_TUBE",
61
+ "fedex_10_kg_box" => "FEDEX_10KG_BOX",
62
+ "fedex_25_kg_box" => "FEDEX_25KG_BOX",
63
+ "your_packaging" => "YOUR_PACKAGING"
64
+ }
65
+
66
+ DROPOFF_TYPES = {
67
+ 'regular_pickup' => 'REGULAR_PICKUP',
68
+ 'request_courier' => 'REQUEST_COURIER',
69
+ 'dropbox' => 'DROP_BOX',
70
+ 'business_service_center' => 'BUSINESS_SERVICE_CENTER',
71
+ 'station' => 'STATION'
72
+ }
73
+
74
+ PAYMENT_TYPES = {
75
+ 'sender' => 'SENDER',
76
+ 'recipient' => 'RECIPIENT',
77
+ 'third_party' => 'THIRDPARTY',
78
+ 'collect' => 'COLLECT'
79
+ }
80
+
81
+ PACKAGE_IDENTIFIER_TYPES = {
82
+ 'tracking_number' => 'TRACKING_NUMBER_OR_DOORTAG',
83
+ 'door_tag' => 'TRACKING_NUMBER_OR_DOORTAG',
84
+ 'rma' => 'RMA',
85
+ 'ground_shipment_id' => 'GROUND_SHIPMENT_ID',
86
+ 'ground_invoice_number' => 'GROUND_INVOICE_NUMBER',
87
+ 'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE',
88
+ 'ground_po' => 'GROUND_PO',
89
+ 'express_reference' => 'EXPRESS_REFERENCE',
90
+ 'express_mps_master' => 'EXPRESS_MPS_MASTER'
91
+ }
92
+
93
+ TRANSIT_TIMES = %w(UNKNOWN ONE_DAY TWO_DAYS THREE_DAYS FOUR_DAYS FIVE_DAYS SIX_DAYS SEVEN_DAYS EIGHT_DAYS NINE_DAYS TEN_DAYS ELEVEN_DAYS TWELVE_DAYS THIRTEEN_DAYS FOURTEEN_DAYS FIFTEEN_DAYS SIXTEEN_DAYS SEVENTEEN_DAYS EIGHTEEN_DAYS)
94
+
95
+ # FedEx tracking codes as described in the FedEx Tracking Service WSDL Guide
96
+ # All delays also have been marked as exceptions
97
+ TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
98
+ 'AA' => :at_airport,
99
+ 'AD' => :at_delivery,
100
+ 'AF' => :at_fedex_facility,
101
+ 'AR' => :at_fedex_facility,
102
+ 'AP' => :at_pickup,
103
+ 'CA' => :canceled,
104
+ 'CH' => :location_changed,
105
+ 'DE' => :exception,
106
+ 'DL' => :delivered,
107
+ 'DP' => :departed_fedex_location,
108
+ 'DR' => :vehicle_furnished_not_used,
109
+ 'DS' => :vehicle_dispatched,
110
+ 'DY' => :exception,
111
+ 'EA' => :exception,
112
+ 'ED' => :enroute_to_delivery,
113
+ 'EO' => :enroute_to_origin_airport,
114
+ 'EP' => :enroute_to_pickup,
115
+ 'FD' => :at_fedex_destination,
116
+ 'HL' => :held_at_location,
117
+ 'IT' => :in_transit,
118
+ 'LO' => :left_origin,
119
+ 'OC' => :order_created,
120
+ 'OD' => :out_for_delivery,
121
+ 'PF' => :plane_in_flight,
122
+ 'PL' => :plane_landed,
123
+ 'PU' => :picked_up,
124
+ 'RS' => :return_to_shipper,
125
+ 'SE' => :exception,
126
+ 'SF' => :at_sort_facility,
127
+ 'SP' => :split_status,
128
+ 'TR' => :transfer
129
+ )
130
+
131
+ def self.service_name_for_code(service_code)
132
+ SERVICE_TYPES[service_code] || "FedEx #{service_code.titleize.sub(/Fedex /, '')}"
133
+ end
134
+
135
+ def requirements
136
+ [:key, :password, :account, :login]
137
+ end
138
+
139
+ def find_rates(origin, destination, packages, options = {})
140
+ options = @options.update(options)
141
+ packages = Array(packages)
142
+
143
+ rate_request = build_rate_request(origin, destination, packages, options)
144
+
145
+ xml = commit(save_request(rate_request), (options[:test] || false))
146
+ response = remove_version_prefix(xml)
147
+
148
+ parse_rate_response(origin, destination, packages, response, options)
149
+ end
150
+
151
+ def find_tracking_info(tracking_number, options = {})
152
+ options = @options.update(options)
153
+
154
+ tracking_request = build_tracking_request(tracking_number, options)
155
+ xml = commit(save_request(tracking_request), (options[:test] || false))
156
+ response = remove_version_prefix(xml)
157
+ parse_tracking_response(response, options)
158
+ end
159
+
160
+ protected
161
+
162
+ def build_rate_request(origin, destination, packages, options = {})
163
+ imperial = %w(US LR MM).include?(origin.country_code(:alpha2))
164
+
165
+ xml_request = XmlNode.new('RateRequest', 'xmlns' => 'http://fedex.com/ws/rate/v13') do |root_node|
166
+ root_node << build_request_header
167
+ root_node << build_version_node
168
+
169
+ # Returns delivery dates
170
+ root_node << XmlNode.new('ReturnTransitAndCommit', true)
171
+ # Returns saturday delivery shipping options when available
172
+ root_node << XmlNode.new('VariableOptions', 'SATURDAY_DELIVERY')
173
+
174
+ root_node << XmlNode.new('RequestedShipment') do |rs|
175
+ rs << XmlNode.new('ShipTimestamp', ship_timestamp(options[:turn_around_time]))
176
+
177
+ freight = has_freight?(options)
178
+
179
+ unless freight
180
+ # fedex api wants this up here otherwise request returns an error
181
+ rs << XmlNode.new('DropoffType', options[:dropoff_type] || 'REGULAR_PICKUP')
182
+ rs << XmlNode.new('PackagingType', options[:packaging_type] || 'YOUR_PACKAGING')
183
+ end
184
+
185
+ rs << build_location_node('Shipper', (options[:shipper] || origin))
186
+ rs << build_location_node('Recipient', destination)
187
+ if options[:shipper] and options[:shipper] != origin
188
+ rs << build_location_node('Origin', origin)
189
+ end
190
+
191
+ if freight
192
+ # build xml for freight rate requests
193
+ freight_options = options[:freight]
194
+ rs << build_shipping_charges_payment_node(freight_options)
195
+ rs << build_freight_shipment_detail_node(freight_options, packages, imperial)
196
+ rs << build_rate_request_types_node
197
+ else
198
+ # build xml for non-freight rate requests
199
+ rs << XmlNode.new('SmartPostDetail') do |spd|
200
+ spd << XmlNode.new('Indicia', options[:smart_post_indicia] || 'PARCEL_SELECT')
201
+ spd << XmlNode.new('HubId', options[:smart_post_hub_id] || 5902) # default to LA
202
+ end
203
+
204
+ rs << build_rate_request_types_node
205
+ rs << XmlNode.new('PackageCount', packages.size)
206
+ rs << build_packages_nodes(packages, imperial)
207
+
208
+ end
209
+ end
210
+ end
211
+ xml_request.to_s
212
+ end
213
+
214
+ def build_packages_nodes(packages, imperial)
215
+ packages.map do |pkg|
216
+ XmlNode.new('RequestedPackageLineItems') do |rps|
217
+ rps << XmlNode.new('GroupPackageCount', 1)
218
+ rps << build_package_weight_node(pkg, imperial)
219
+ rps << build_package_dimensions_node(pkg, imperial)
220
+ end
221
+ end
222
+ end
223
+
224
+ def build_shipping_charges_payment_node(freight_options)
225
+ XmlNode.new('ShippingChargesPayment') do |shipping_charges_payment|
226
+ shipping_charges_payment << XmlNode.new('PaymentType', freight_options[:payment_type])
227
+ shipping_charges_payment << XmlNode.new('Payor') do |payor|
228
+ payor << XmlNode.new('ResponsibleParty') do |responsible_party|
229
+ # TODO: case of different freight account numbers?
230
+ responsible_party << XmlNode.new('AccountNumber', freight_options[:account])
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ def build_freight_shipment_detail_node(freight_options, packages, imperial)
237
+ XmlNode.new('FreightShipmentDetail') do |freight_shipment_detail|
238
+ # TODO: case of different freight account numbers?
239
+ freight_shipment_detail << XmlNode.new('FedExFreightAccountNumber', freight_options[:account])
240
+ freight_shipment_detail << build_location_node('FedExFreightBillingContactAndAddress', freight_options[:billing_location])
241
+ freight_shipment_detail << XmlNode.new('Role', freight_options[:role])
242
+
243
+ packages.each do |pkg|
244
+ freight_shipment_detail << XmlNode.new('LineItems') do |line_items|
245
+ line_items << XmlNode.new('FreightClass', freight_options[:freight_class])
246
+ line_items << XmlNode.new('Packaging', freight_options[:packaging])
247
+ line_items << build_package_weight_node(pkg, imperial)
248
+ line_items << build_package_dimensions_node(pkg, imperial)
249
+ end
250
+ end
251
+ end
252
+ end
253
+
254
+ def has_freight?(options)
255
+ options[:freight] && options[:freight].present?
256
+ end
257
+
258
+ def build_package_weight_node(pkg, imperial)
259
+ XmlNode.new('Weight') do |tw|
260
+ tw << XmlNode.new('Units', imperial ? 'LB' : 'KG')
261
+ tw << XmlNode.new('Value', [((imperial ? pkg.lbs : pkg.kgs).to_f * 1000).round / 1000.0, 0.1].max)
262
+ end
263
+ end
264
+
265
+ def build_version_node
266
+ XmlNode.new('Version') do |version_node|
267
+ version_node << XmlNode.new('ServiceId', 'crs')
268
+ version_node << XmlNode.new('Major', '13')
269
+ version_node << XmlNode.new('Intermediate', '0')
270
+ version_node << XmlNode.new('Minor', '0')
271
+ end
272
+ end
273
+
274
+ def build_package_dimensions_node(pkg, imperial)
275
+ XmlNode.new('Dimensions') do |dimensions|
276
+ [:length, :width, :height].each do |axis|
277
+ value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
278
+ dimensions << XmlNode.new(axis.to_s.capitalize, value.ceil)
279
+ end
280
+ dimensions << XmlNode.new('Units', imperial ? 'IN' : 'CM')
281
+ end
282
+ end
283
+
284
+ def build_rate_request_types_node(type = 'ACCOUNT')
285
+ XmlNode.new('RateRequestTypes', type)
286
+ end
287
+
288
+ def build_tracking_request(tracking_number, options = {})
289
+ xml_request = XmlNode.new('TrackRequest', 'xmlns' => 'http://fedex.com/ws/track/v3') do |root_node|
290
+ root_node << build_request_header
291
+
292
+ # Version
293
+ root_node << XmlNode.new('Version') do |version_node|
294
+ version_node << XmlNode.new('ServiceId', 'trck')
295
+ version_node << XmlNode.new('Major', '3')
296
+ version_node << XmlNode.new('Intermediate', '0')
297
+ version_node << XmlNode.new('Minor', '0')
298
+ end
299
+
300
+ root_node << XmlNode.new('PackageIdentifier') do |package_node|
301
+ package_node << XmlNode.new('Value', tracking_number)
302
+ package_node << XmlNode.new('Type', PACKAGE_IDENTIFIER_TYPES[options['package_identifier_type'] || 'tracking_number'])
303
+ end
304
+
305
+ root_node << XmlNode.new('ShipDateRangeBegin', options['ship_date_range_begin']) if options['ship_date_range_begin']
306
+ root_node << XmlNode.new('ShipDateRangeEnd', options['ship_date_range_end']) if options['ship_date_range_end']
307
+ root_node << XmlNode.new('IncludeDetailedScans', 1)
308
+ end
309
+ xml_request.to_s
310
+ end
311
+
312
+ def build_request_header
313
+ web_authentication_detail = XmlNode.new('WebAuthenticationDetail') do |wad|
314
+ wad << XmlNode.new('UserCredential') do |uc|
315
+ uc << XmlNode.new('Key', @options[:key])
316
+ uc << XmlNode.new('Password', @options[:password])
317
+ end
318
+ end
319
+
320
+ client_detail = XmlNode.new('ClientDetail') do |cd|
321
+ cd << XmlNode.new('AccountNumber', @options[:account])
322
+ cd << XmlNode.new('MeterNumber', @options[:login])
323
+ end
324
+
325
+ trasaction_detail = XmlNode.new('TransactionDetail') do |td|
326
+ td << XmlNode.new('CustomerTransactionId', @options[:transaction_id] || 'ActiveShipping') # TODO: Need to do something better with this..
327
+ end
328
+
329
+ [web_authentication_detail, client_detail, trasaction_detail]
330
+ end
331
+
332
+ def build_location_node(name, location)
333
+ XmlNode.new(name) do |xml_node|
334
+ xml_node << XmlNode.new('Address') do |address_node|
335
+ address_node << XmlNode.new('StreetLines', location.address1) if location.address1
336
+ address_node << XmlNode.new('StreetLines', location.address2) if location.address2
337
+ address_node << XmlNode.new('City', location.city) if location.city
338
+ address_node << XmlNode.new('PostalCode', location.postal_code)
339
+ address_node << XmlNode.new("CountryCode", location.country_code(:alpha2))
340
+
341
+ address_node << XmlNode.new("Residential", true) unless location.commercial?
342
+ end
343
+ end
344
+ end
345
+
346
+ def parse_rate_response(origin, destination, packages, response, options)
347
+ rate_estimates = []
348
+
349
+ xml = build_document(response)
350
+ root_node = xml.elements['RateReply']
351
+
352
+ success = response_success?(xml)
353
+ message = response_message(xml)
354
+
355
+ raise ActiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml) unless root_node
356
+
357
+ root_node.elements.each('RateReplyDetails') do |rated_shipment|
358
+ service_code = rated_shipment.get_text('ServiceType').to_s
359
+ is_saturday_delivery = rated_shipment.get_text('AppliedOptions').to_s == 'SATURDAY_DELIVERY'
360
+ service_type = is_saturday_delivery ? "#{service_code}_SATURDAY_DELIVERY" : service_code
361
+
362
+ transit_time = rated_shipment.get_text('TransitTime').to_s if service_code == "FEDEX_GROUND"
363
+ max_transit_time = rated_shipment.get_text('MaximumTransitTime').to_s if service_code == "FEDEX_GROUND"
364
+
365
+ delivery_timestamp = rated_shipment.get_text('DeliveryTimestamp').to_s
366
+
367
+ delivery_range = delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options)
368
+
369
+ currency = rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').to_s
370
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
371
+ self.class.service_name_for_code(service_type),
372
+ :service_code => service_code,
373
+ :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f,
374
+ :currency => currency,
375
+ :packages => packages,
376
+ :delivery_range => delivery_range)
377
+ end
378
+
379
+ if rate_estimates.empty?
380
+ success = false
381
+ message = "No shipping rates could be found for the destination address" if message.blank?
382
+ end
383
+
384
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request, :log_xml => options[:log_xml])
385
+ end
386
+
387
+ def delivery_range_from(transit_time, max_transit_time, delivery_timestamp, options)
388
+ delivery_range = [delivery_timestamp, delivery_timestamp]
389
+
390
+ # if there's no delivery timestamp but we do have a transit time, use it
391
+ if delivery_timestamp.blank? && transit_time.present?
392
+ transit_range = parse_transit_times([transit_time, max_transit_time.presence || transit_time])
393
+ delivery_range = transit_range.map { |days| business_days_from(ship_date(options[:turn_around_time]), days) }
394
+ end
395
+
396
+ delivery_range
397
+ end
398
+
399
+ def business_days_from(date, days)
400
+ future_date = date
401
+ count = 0
402
+
403
+ while count < days
404
+ future_date += 1.day
405
+ count += 1 if business_day?(future_date)
406
+ end
407
+
408
+ future_date
409
+ end
410
+
411
+ def business_day?(date)
412
+ (1..5).include?(date.wday)
413
+ end
414
+
415
+ def parse_tracking_response(response, options)
416
+ xml = build_document(response)
417
+ root_node = xml.elements['TrackReply']
418
+
419
+ success = response_success?(xml)
420
+ message = response_message(xml)
421
+
422
+ if success
423
+ origin = nil
424
+ delivery_signature = nil
425
+ shipment_events = []
426
+
427
+ tracking_details = root_node.elements['TrackDetails']
428
+ tracking_number = tracking_details.get_text('TrackingNumber').to_s
429
+ status_code = tracking_details.get_text('StatusCode').to_s
430
+ status_description = tracking_details.get_text('StatusDescription').to_s
431
+ status = TRACKING_STATUS_CODES[status_code]
432
+
433
+ if status_code == 'DL' && tracking_details.get_text('SignatureProofOfDeliveryAvailable').to_s == 'true'
434
+ delivery_signature = tracking_details.get_text('DeliverySignatureName').to_s
435
+ end
436
+
437
+ origin_node = tracking_details.elements['OriginLocationAddress']
438
+
439
+ if origin_node
440
+ origin = Location.new(
441
+ :country => origin_node.get_text('CountryCode').to_s,
442
+ :province => origin_node.get_text('StateOrProvinceCode').to_s,
443
+ :city => origin_node.get_text('City').to_s
444
+ )
445
+ end
446
+
447
+ destination = extract_address(tracking_details, DELIVERY_ADDRESS_NODE_NAMES)
448
+ shipper_address = extract_address(tracking_details, SHIPPER_ADDRESS_NODE_NAMES)
449
+
450
+ ship_time = extract_timestamp(tracking_details, 'ShipTimestamp')
451
+ actual_delivery_time = extract_timestamp(tracking_details, 'ActualDeliveryTimestamp')
452
+ scheduled_delivery_time = extract_timestamp(tracking_details, 'EstimatedDeliveryTimestamp')
453
+
454
+ tracking_details.elements.each('Events') do |event|
455
+ address = event.elements['Address']
456
+
457
+ city = address.get_text('City').to_s
458
+ state = address.get_text('StateOrProvinceCode').to_s
459
+ zip_code = address.get_text('PostalCode').to_s
460
+ country = address.get_text('CountryCode').to_s
461
+ next if country.blank?
462
+
463
+ location = Location.new(:city => city, :state => state, :postal_code => zip_code, :country => country)
464
+ description = event.get_text('EventDescription').to_s
465
+
466
+ time = Time.parse("#{event.get_text('Timestamp').to_s}")
467
+ zoneless_time = time.utc
468
+
469
+ shipment_events << ShipmentEvent.new(description, zoneless_time, location)
470
+ end
471
+ shipment_events = shipment_events.sort_by(&:time)
472
+
473
+ end
474
+
475
+ TrackingResponse.new(success, message, Hash.from_xml(response),
476
+ :carrier => @@name,
477
+ :xml => response,
478
+ :request => last_request,
479
+ :status => status,
480
+ :status_code => status_code,
481
+ :status_description => status_description,
482
+ :ship_time => ship_time,
483
+ :scheduled_delivery_date => scheduled_delivery_time,
484
+ :actual_delivery_date => actual_delivery_time,
485
+ :delivery_signature => delivery_signature,
486
+ :shipment_events => shipment_events,
487
+ :shipper_address => (shipper_address.nil? || shipper_address.unknown?) ? nil : shipper_address,
488
+ :origin => origin,
489
+ :destination => destination,
490
+ :tracking_number => tracking_number
491
+ )
492
+ end
493
+
494
+ def ship_timestamp(delay_in_hours)
495
+ delay_in_hours ||= 0
496
+ Time.now + delay_in_hours.hours
497
+ end
498
+
499
+ def ship_date(delay_in_hours)
500
+ delay_in_hours ||= 0
501
+ (Time.now + delay_in_hours.hours).to_date
502
+ end
503
+
504
+ def response_status_node(document)
505
+ document.elements['/*/Notifications/']
506
+ end
507
+
508
+ def response_success?(document)
509
+ response_node = response_status_node(document)
510
+ return false if response_node.nil?
511
+
512
+ %w(SUCCESS WARNING NOTE).include? response_node.get_text('Severity').to_s
513
+ end
514
+
515
+ def response_message(document)
516
+ response_node = response_status_node(document)
517
+ return "" if response_node.nil?
518
+
519
+ "#{response_node.get_text('Severity')} - #{response_node.get_text('Code')}: #{response_node.get_text('Message')}"
520
+ end
521
+
522
+ def commit(request, test = false)
523
+ ssl_post(test ? TEST_URL : LIVE_URL, request.gsub("\n", ''))
524
+ end
525
+
526
+ def parse_transit_times(times)
527
+ results = []
528
+ times.each do |day_count|
529
+ days = TRANSIT_TIMES.index(day_count.to_s.chomp)
530
+ results << days.to_i
531
+ end
532
+ results
533
+ end
534
+
535
+ def extract_address(document, possible_node_names)
536
+ node = nil
537
+ possible_node_names.each do |name|
538
+ node ||= document.elements[name]
539
+ break if node
540
+ end
541
+
542
+ args = if node && node.elements['CountryCode']
543
+ {
544
+ :country => node.get_text('CountryCode').to_s,
545
+ :province => node.get_text('StateOrProvinceCode').to_s,
546
+ :city => node.get_text('City').to_s
547
+ }
548
+ else
549
+ {
550
+ :country => ActiveUtils::Country.new(:alpha2 => 'ZZ', :name => 'Unknown or Invalid Territory', :alpha3 => 'ZZZ', :numeric => '999'),
551
+ :province => 'unknown',
552
+ :city => 'unknown'
553
+ }
554
+ end
555
+
556
+ Location.new(args)
557
+ end
558
+
559
+ def extract_timestamp(document, node_name)
560
+ if timestamp_node = document.elements[node_name]
561
+ Time.parse(timestamp_node.to_s).utc
562
+ end
563
+ end
564
+
565
+ def remove_version_prefix(xml)
566
+ if xml =~ /xmlns:v[0-9]/
567
+ xml.gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
568
+ else
569
+ xml
570
+ end
571
+ end
572
+
573
+ def build_document(xml)
574
+ REXML::Document.new(xml)
575
+ rescue REXML::ParseException => e
576
+ raise ActiveShipping::ResponseContentError.new(e, xml)
577
+ end
578
+ end
579
+ end