reactive_shipping 3.0.0

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 (247) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.travis.yml +33 -0
  4. data/.yardopts +13 -0
  5. data/CHANGELOG.md +225 -0
  6. data/CONTRIBUTING.md +23 -0
  7. data/Gemfile +3 -0
  8. data/MIT-LICENSE +21 -0
  9. data/README.md +158 -0
  10. data/Rakefile +35 -0
  11. data/dev.yml +17 -0
  12. data/gemfiles/activesupport42.gemfile +5 -0
  13. data/gemfiles/activesupport50.gemfile +6 -0
  14. data/gemfiles/activesupport51.gemfile +5 -0
  15. data/gemfiles/activesupport52.gemfile +5 -0
  16. data/gemfiles/activesupport_master.gemfile +5 -0
  17. data/lib/certs/eParcel.dtd +111 -0
  18. data/lib/reactive_shipping.rb +26 -0
  19. data/lib/reactive_shipping/address_validation_response.rb +30 -0
  20. data/lib/reactive_shipping/carrier.rb +184 -0
  21. data/lib/reactive_shipping/carriers.rb +35 -0
  22. data/lib/reactive_shipping/carriers/australia_post.rb +248 -0
  23. data/lib/reactive_shipping/carriers/benchmark_carrier.rb +31 -0
  24. data/lib/reactive_shipping/carriers/bogus_carrier.rb +12 -0
  25. data/lib/reactive_shipping/carriers/canada_post.rb +263 -0
  26. data/lib/reactive_shipping/carriers/canada_post_pws.rb +908 -0
  27. data/lib/reactive_shipping/carriers/fedex.rb +797 -0
  28. data/lib/reactive_shipping/carriers/kunaki.rb +155 -0
  29. data/lib/reactive_shipping/carriers/new_zealand_post.rb +260 -0
  30. data/lib/reactive_shipping/carriers/shipwire.rb +178 -0
  31. data/lib/reactive_shipping/carriers/stamps.rb +860 -0
  32. data/lib/reactive_shipping/carriers/ups.rb +1060 -0
  33. data/lib/reactive_shipping/carriers/usps.rb +708 -0
  34. data/lib/reactive_shipping/carriers/usps_returns.rb +86 -0
  35. data/lib/reactive_shipping/delivery_date_estimate.rb +20 -0
  36. data/lib/reactive_shipping/delivery_date_estimates_response.rb +11 -0
  37. data/lib/reactive_shipping/errors.rb +35 -0
  38. data/lib/reactive_shipping/external_return_label_request.rb +417 -0
  39. data/lib/reactive_shipping/external_return_label_response.rb +26 -0
  40. data/lib/reactive_shipping/label.rb +10 -0
  41. data/lib/reactive_shipping/label_response.rb +10 -0
  42. data/lib/reactive_shipping/location.rb +166 -0
  43. data/lib/reactive_shipping/package.rb +165 -0
  44. data/lib/reactive_shipping/package_item.rb +60 -0
  45. data/lib/reactive_shipping/rate_estimate.rb +197 -0
  46. data/lib/reactive_shipping/rate_response.rb +33 -0
  47. data/lib/reactive_shipping/response.rb +44 -0
  48. data/lib/reactive_shipping/shipment_event.rb +22 -0
  49. data/lib/reactive_shipping/shipment_packer.rb +108 -0
  50. data/lib/reactive_shipping/shipping_response.rb +34 -0
  51. data/lib/reactive_shipping/tracking_response.rb +120 -0
  52. data/lib/reactive_shipping/version.rb +3 -0
  53. data/reactive_shipping.gemspec +38 -0
  54. data/shipit.rubygems.yml +1 -0
  55. data/test/console.rb +39 -0
  56. data/test/credentials.yml +76 -0
  57. data/test/fixtures/files/label1.pdf +0 -0
  58. data/test/fixtures/files/ups-shipping-label.gif +0 -0
  59. data/test/fixtures/json/australia_post/calculate_domestic.json +13 -0
  60. data/test/fixtures/json/australia_post/calculate_domestic_2.json +19 -0
  61. data/test/fixtures/json/australia_post/calculate_international.json +12 -0
  62. data/test/fixtures/json/australia_post/calculate_international_2.json +15 -0
  63. data/test/fixtures/json/australia_post/error_message.json +5 -0
  64. data/test/fixtures/json/australia_post/service_domestic.json +117 -0
  65. data/test/fixtures/json/australia_post/service_domestic_2.json +117 -0
  66. data/test/fixtures/json/australia_post/service_international.json +76 -0
  67. data/test/fixtures/json/australia_post/service_international_2.json +59 -0
  68. data/test/fixtures/json/newzealandpost/domestic_book.json +1 -0
  69. data/test/fixtures/json/newzealandpost/domestic_default.json +1 -0
  70. data/test/fixtures/json/newzealandpost/domestic_error.json +1 -0
  71. data/test/fixtures/json/newzealandpost/domestic_poster.json +1 -0
  72. data/test/fixtures/json/newzealandpost/domestic_small_half_pound.json +1 -0
  73. data/test/fixtures/json/newzealandpost/international_book.json +1 -0
  74. data/test/fixtures/json/newzealandpost/international_new_zealand_wii.json +1 -0
  75. data/test/fixtures/json/newzealandpost/international_small_half_pound.json +1 -0
  76. data/test/fixtures/json/newzealandpost/international_wii.json +1 -0
  77. data/test/fixtures/xml/canadapost/example_request.xml +25 -0
  78. data/test/fixtures/xml/canadapost/example_response.xml +130 -0
  79. data/test/fixtures/xml/canadapost/example_response_error.xml +16 -0
  80. data/test/fixtures/xml/canadapost/example_response_french.xml +122 -0
  81. data/test/fixtures/xml/canadapost/example_response_with_nil_value.xml +164 -0
  82. data/test/fixtures/xml/canadapost/example_response_with_postal_outlet.xml +155 -0
  83. data/test/fixtures/xml/canadapost/example_response_with_postal_outlet_french.xml +274 -0
  84. data/test/fixtures/xml/canadapost/example_response_with_strange_delivery_date.xml +130 -0
  85. data/test/fixtures/xml/canadapost_pws/dnc_tracking_details_en.xml +112 -0
  86. data/test/fixtures/xml/canadapost_pws/merchant_details_error.xml +7 -0
  87. data/test/fixtures/xml/canadapost_pws/merchant_details_response.xml +7 -0
  88. data/test/fixtures/xml/canadapost_pws/option_response.xml +13 -0
  89. data/test/fixtures/xml/canadapost_pws/option_response_no_conflicts.xml +7 -0
  90. data/test/fixtures/xml/canadapost_pws/rates_info.xml +190 -0
  91. data/test/fixtures/xml/canadapost_pws/rates_info_error.xml +7 -0
  92. data/test/fixtures/xml/canadapost_pws/receipt_response.xml +42 -0
  93. data/test/fixtures/xml/canadapost_pws/receipt_response_no_priced_options.xml +36 -0
  94. data/test/fixtures/xml/canadapost_pws/register_token_error.xml +7 -0
  95. data/test/fixtures/xml/canadapost_pws/register_token_response.xml +3 -0
  96. data/test/fixtures/xml/canadapost_pws/service_options_response.xml +42 -0
  97. data/test/fixtures/xml/canadapost_pws/services_error.xml +6 -0
  98. data/test/fixtures/xml/canadapost_pws/services_response.xml +32 -0
  99. data/test/fixtures/xml/canadapost_pws/shipment_domestic.xml +69 -0
  100. data/test/fixtures/xml/canadapost_pws/shipment_response.xml +20 -0
  101. data/test/fixtures/xml/canadapost_pws/shipment_us.xml +69 -0
  102. data/test/fixtures/xml/canadapost_pws/tracking_details_en.xml +152 -0
  103. data/test/fixtures/xml/canadapost_pws/tracking_details_en_error.xml +7 -0
  104. data/test/fixtures/xml/canadapost_pws/tracking_details_en_undelivered.xml +116 -0
  105. data/test/fixtures/xml/canadapost_pws/tracking_details_fr.xml +156 -0
  106. data/test/fixtures/xml/canadapost_pws/tracking_details_no_expected_delivery_date.xml +40 -0
  107. data/test/fixtures/xml/fedex/create_shipment_response.xml +2 -0
  108. data/test/fixtures/xml/fedex/freight_rate_request.xml +82 -0
  109. data/test/fixtures/xml/fedex/freight_rate_response.xml +506 -0
  110. data/test/fixtures/xml/fedex/invalid_fedex_reply.xml +27 -0
  111. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_commercial_rate_request.xml +79 -0
  112. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_no_saturday_rate_request.xml +79 -0
  113. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_request.xml +80 -0
  114. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_response.xml +214 -0
  115. data/test/fixtures/xml/fedex/raterequest_reply.xml +213 -0
  116. data/test/fixtures/xml/fedex/raterequest_response_with_ground_home_delivery.xml +206 -0
  117. data/test/fixtures/xml/fedex/reply_without_notifications.xml +185 -0
  118. data/test/fixtures/xml/fedex/tracking_request.xml +29 -0
  119. data/test/fixtures/xml/fedex/tracking_response_bad_tracking_number.xml +20 -0
  120. data/test/fixtures/xml/fedex/tracking_response_delivered_at_door.xml +254 -0
  121. data/test/fixtures/xml/fedex/tracking_response_delivered_at_facility.xml +403 -0
  122. data/test/fixtures/xml/fedex/tracking_response_delivered_with_signature.xml +269 -0
  123. data/test/fixtures/xml/fedex/tracking_response_empty_status_detail.xml +84 -0
  124. data/test/fixtures/xml/fedex/tracking_response_failure_code_9045.xml +52 -0
  125. data/test/fixtures/xml/fedex/tracking_response_failure_code_9080.xml +51 -0
  126. data/test/fixtures/xml/fedex/tracking_response_in_transit.xml +127 -0
  127. data/test/fixtures/xml/fedex/tracking_response_invalid_tracking_number.xml +52 -0
  128. data/test/fixtures/xml/fedex/tracking_response_missing_status_code.xml +89 -0
  129. data/test/fixtures/xml/fedex/tracking_response_multiple_results.xml +100 -0
  130. data/test/fixtures/xml/fedex/tracking_response_not_found.xml +52 -0
  131. data/test/fixtures/xml/fedex/tracking_response_shipment_exception.xml +209 -0
  132. data/test/fixtures/xml/fedex/tracking_response_unable_to_process.xml +32 -0
  133. data/test/fixtures/xml/fedex/tracking_response_with_blank_state.xml +107 -0
  134. data/test/fixtures/xml/fedex/unknown_fedex_document_reply.xml +3 -0
  135. data/test/fixtures/xml/kunaki/invalid_state_response.xml +3 -0
  136. data/test/fixtures/xml/kunaki/no_valid_items_response.xml +3 -0
  137. data/test/fixtures/xml/kunaki/successful_rates_response.xml +3 -0
  138. data/test/fixtures/xml/kunaki/unsuccessful_rates_response.xml +9 -0
  139. data/test/fixtures/xml/shipwire/international_rates_response.xml +17 -0
  140. data/test/fixtures/xml/shipwire/new_carrier_rate_response.xml +18 -0
  141. data/test/fixtures/xml/shipwire/no_rates_response.xml +7 -0
  142. data/test/fixtures/xml/shipwire/rates_response.xml +36 -0
  143. data/test/fixtures/xml/shipwire/rates_response_no_estimate.xml +14 -0
  144. data/test/fixtures/xml/stamps/authenticate_user_request.xml +15 -0
  145. data/test/fixtures/xml/stamps/authenticate_user_response.xml +10 -0
  146. data/test/fixtures/xml/stamps/cleanse_address_request.xml +19 -0
  147. data/test/fixtures/xml/stamps/cleanse_address_response.xml +27 -0
  148. data/test/fixtures/xml/stamps/create_indicium_request.xml +69 -0
  149. data/test/fixtures/xml/stamps/create_indicium_response.xml +40 -0
  150. data/test/fixtures/xml/stamps/expired_authenticator_response.xml +15 -0
  151. data/test/fixtures/xml/stamps/get_account_info_request.xml +11 -0
  152. data/test/fixtures/xml/stamps/get_account_info_response.xml +36 -0
  153. data/test/fixtures/xml/stamps/get_purchase_status_request.xml +12 -0
  154. data/test/fixtures/xml/stamps/get_purchase_status_response.xml +16 -0
  155. data/test/fixtures/xml/stamps/get_rates_request.xml +19 -0
  156. data/test/fixtures/xml/stamps/get_rates_response.xml +351 -0
  157. data/test/fixtures/xml/stamps/purchase_postage_request.xml +13 -0
  158. data/test/fixtures/xml/stamps/purchase_postage_response.xml +17 -0
  159. data/test/fixtures/xml/stamps/track_shipment_request.xml +12 -0
  160. data/test/fixtures/xml/stamps/track_shipment_response.xml +45 -0
  161. data/test/fixtures/xml/ups/access_request.xml +6 -0
  162. data/test/fixtures/xml/ups/delivered_shipment_with_refund.xml +290 -0
  163. data/test/fixtures/xml/ups/delivered_shipment_without_events_tracking_response.xml +62 -0
  164. data/test/fixtures/xml/ups/delivery_dates_response.xml +140 -0
  165. data/test/fixtures/xml/ups/example_tracking_response.xml +53 -0
  166. data/test/fixtures/xml/ups/in_transit_shipment.xml +183 -0
  167. data/test/fixtures/xml/ups/out_for_delivery_shipment.xml +165 -0
  168. data/test/fixtures/xml/ups/package_exceeds_maximum_length.xml +12 -0
  169. data/test/fixtures/xml/ups/rate_single_service.xml +54 -0
  170. data/test/fixtures/xml/ups/rescheduled_shipment.xml +204 -0
  171. data/test/fixtures/xml/ups/shipment_accept_response.xml +42 -0
  172. data/test/fixtures/xml/ups/shipment_confirm_response.xml +33 -0
  173. data/test/fixtures/xml/ups/shipment_from_tiger_direct.xml +222 -0
  174. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_response.xml +290 -0
  175. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_response_with_insured.xml +289 -0
  176. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_with_origin_account_response.xml +311 -0
  177. data/test/fixtures/xml/ups/tracking_request.xml +9 -0
  178. data/test/fixtures/xml/ups/triple_accept_response.xml +72 -0
  179. data/test/fixtures/xml/ups/triple_confirm_response.xml +32 -0
  180. data/test/fixtures/xml/ups/void_shipment_response.xml +11 -0
  181. data/test/fixtures/xml/usps/api_error_rate_response.xml +53 -0
  182. data/test/fixtures/xml/usps/beverly_hills_to_new_york_book_commercial_base_rate_response.xml +2 -0
  183. data/test/fixtures/xml/usps/beverly_hills_to_new_york_book_commercial_plus_rate_response.xml +258 -0
  184. data/test/fixtures/xml/usps/beverly_hills_to_new_york_book_rate_response.xml +108 -0
  185. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_american_wii_commercial_base_rate_response.xml +84 -0
  186. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_american_wii_commercial_plus_rate_response.xml +212 -0
  187. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_american_wii_rate_response.xml +230 -0
  188. data/test/fixtures/xml/usps/first_class_packages_with_invalid_mail_type_response.xml +12 -0
  189. data/test/fixtures/xml/usps/first_class_packages_with_mail_type_response.xml +16 -0
  190. data/test/fixtures/xml/usps/first_class_packages_without_mail_type_response.xml +12 -0
  191. data/test/fixtures/xml/usps/invalid_xml_response.xml +10 -0
  192. data/test/fixtures/xml/usps/invalid_xml_tracking_response_error.xml +2 -0
  193. data/test/fixtures/xml/usps/tracking_request.xml +10 -0
  194. data/test/fixtures/xml/usps/tracking_request_batch.xml +12 -0
  195. data/test/fixtures/xml/usps/tracking_response.xml +162 -0
  196. data/test/fixtures/xml/usps/tracking_response_alt.xml +53 -0
  197. data/test/fixtures/xml/usps/tracking_response_batch.xml +231 -0
  198. data/test/fixtures/xml/usps/tracking_response_failure.xml +11 -0
  199. data/test/fixtures/xml/usps/tracking_response_not_available.xml +12 -0
  200. data/test/fixtures/xml/usps/tracking_response_test_error.xml +8 -0
  201. data/test/fixtures/xml/usps/us_rate_request.xml +18 -0
  202. data/test/fixtures/xml/usps/us_rate_request_large.xml +18 -0
  203. data/test/fixtures/xml/usps/world_rate_request_only_country.xml +22 -0
  204. data/test/fixtures/xml/usps/world_rate_request_with_value.xml +24 -0
  205. data/test/fixtures/xml/usps/world_rate_request_without_value.xml +24 -0
  206. data/test/fixtures/xml/usps_returns/external_return_label_response.xml +2 -0
  207. data/test/fixtures/xml/usps_returns/external_return_label_response_failure.xml +10 -0
  208. data/test/remote/australia_post_test.rb +140 -0
  209. data/test/remote/canada_post_pws_platform_test.rb +259 -0
  210. data/test/remote/canada_post_pws_test.rb +169 -0
  211. data/test/remote/canada_post_test.rb +55 -0
  212. data/test/remote/fedex_test.rb +400 -0
  213. data/test/remote/kunaki_test.rb +37 -0
  214. data/test/remote/new_zealand_post_test.rb +149 -0
  215. data/test/remote/shipwire_test.rb +84 -0
  216. data/test/remote/stamps_test.rb +396 -0
  217. data/test/remote/usps_returns_test.rb +72 -0
  218. data/test/remote/usps_test.rb +243 -0
  219. data/test/test_helper.rb +296 -0
  220. data/test/unit/carrier_test.rb +130 -0
  221. data/test/unit/carriers/australia_post_test.rb +181 -0
  222. data/test/unit/carriers/benchmark_test.rb +18 -0
  223. data/test/unit/carriers/canada_post_pws_rating_test.rb +379 -0
  224. data/test/unit/carriers/canada_post_pws_register_test.rb +76 -0
  225. data/test/unit/carriers/canada_post_pws_shipping_test.rb +258 -0
  226. data/test/unit/carriers/canada_post_pws_test.rb +59 -0
  227. data/test/unit/carriers/canada_post_pws_tracking_test.rb +154 -0
  228. data/test/unit/carriers/canada_post_test.rb +148 -0
  229. data/test/unit/carriers/fedex_test.rb +693 -0
  230. data/test/unit/carriers/kunaki_test.rb +56 -0
  231. data/test/unit/carriers/new_zealand_post_test.rb +177 -0
  232. data/test/unit/carriers/shipwire_test.rb +188 -0
  233. data/test/unit/carriers/stamps_test.rb +245 -0
  234. data/test/unit/carriers/ups_test.rb +580 -0
  235. data/test/unit/carriers/usps_returns_test.rb +45 -0
  236. data/test/unit/carriers/usps_test.rb +633 -0
  237. data/test/unit/carriers_test.rb +16 -0
  238. data/test/unit/external_return_label_request_test.rb +258 -0
  239. data/test/unit/location_test.rb +234 -0
  240. data/test/unit/package_item_test.rb +232 -0
  241. data/test/unit/package_test.rb +404 -0
  242. data/test/unit/rate_estimate_test.rb +93 -0
  243. data/test/unit/response_test.rb +38 -0
  244. data/test/unit/shipment_event_test.rb +20 -0
  245. data/test/unit/shipment_packer_test.rb +212 -0
  246. data/test/unit/tracking_response_test.rb +41 -0
  247. metadata +684 -0
@@ -0,0 +1,1060 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ module ReactiveShipping
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
+ :delivery_dates => 'ups.app/xml/TimeInTransit',
20
+ :void => 'ups.app/xml/Void'
21
+ }
22
+
23
+ PICKUP_CODES = HashWithIndifferentAccess.new(
24
+ :daily_pickup => "01",
25
+ :customer_counter => "03",
26
+ :one_time_pickup => "06",
27
+ :on_call_air => "07",
28
+ :suggested_retail_rates => "11",
29
+ :letter_center => "19",
30
+ :air_service_center => "20"
31
+ )
32
+
33
+ CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new(
34
+ :wholesale => "01",
35
+ :occasional => "03",
36
+ :retail => "04"
37
+ )
38
+
39
+ # these are the defaults described in the UPS API docs,
40
+ # but they don't seem to apply them under all circumstances,
41
+ # so we need to take matters into our own hands
42
+ DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash, key|
43
+ hash[key] = case key.to_sym
44
+ when :daily_pickup then :wholesale
45
+ when :customer_counter then :retail
46
+ else
47
+ :occasional
48
+ end
49
+ end
50
+
51
+ DEFAULT_SERVICES = {
52
+ "01" => "UPS Next Day Air",
53
+ "02" => "UPS Second Day Air",
54
+ "03" => "UPS Ground",
55
+ "07" => "UPS Worldwide Express",
56
+ "08" => "UPS Worldwide Expedited",
57
+ "11" => "UPS Standard",
58
+ "12" => "UPS Three-Day Select",
59
+ "13" => "UPS Next Day Air Saver",
60
+ "14" => "UPS Next Day Air Early A.M.",
61
+ "54" => "UPS Worldwide Express Plus",
62
+ "59" => "UPS Second Day Air A.M.",
63
+ "65" => "UPS Saver",
64
+ "82" => "UPS Today Standard",
65
+ "83" => "UPS Today Dedicated Courier",
66
+ "84" => "UPS Today Intercity",
67
+ "85" => "UPS Today Express",
68
+ "86" => "UPS Today Express Saver",
69
+ "92" => "UPS SurePost (USPS) < 1lb",
70
+ "93" => "UPS SurePost (USPS) > 1lb",
71
+ "94" => "UPS SurePost (USPS) BPM",
72
+ "95" => "UPS SurePost (USPS) Media",
73
+ }
74
+
75
+ CANADA_ORIGIN_SERVICES = {
76
+ "01" => "UPS Express",
77
+ "02" => "UPS Expedited",
78
+ "14" => "UPS Express Early A.M."
79
+ }
80
+
81
+ MEXICO_ORIGIN_SERVICES = {
82
+ "07" => "UPS Express",
83
+ "08" => "UPS Expedited",
84
+ "54" => "UPS Express Plus"
85
+ }
86
+
87
+ EU_ORIGIN_SERVICES = {
88
+ "07" => "UPS Express",
89
+ "08" => "UPS Expedited"
90
+ }
91
+
92
+ OTHER_NON_US_ORIGIN_SERVICES = {
93
+ "07" => "UPS Express"
94
+ }
95
+
96
+ RETURN_SERVICE_CODES = {
97
+ "2" => "UPS Print and Mail (PNM)",
98
+ "3" => "UPS Return Service 1-Attempt (RS1)",
99
+ "5" => "UPS Return Service 3-Attempt (RS3)",
100
+ "8" => "UPS Electronic Return Label (ERL)",
101
+ "9" => "UPS Print Return Label (PRL)",
102
+ "10" => "UPS Exchange Print Return Label",
103
+ "11" => "UPS Pack & Collect Service 1-Attempt Box 1",
104
+ "12" => "UPS Pack & Collect Service 1-Attempt Box 2",
105
+ "13" => "UPS Pack & Collect Service 1-Attempt Box 3",
106
+ "14" => "UPS Pack & Collect Service 1-Attempt Box 4",
107
+ "15" => "UPS Pack & Collect Service 1-Attempt Box 5",
108
+ "16" => "UPS Pack & Collect Service 3-Attempt Box 1",
109
+ "17" => "UPS Pack & Collect Service 3-Attempt Box 2",
110
+ "18" => "UPS Pack & Collect Service 3-Attempt Box 3",
111
+ "19" => "UPS Pack & Collect Service 3-Attempt Box 4",
112
+ "20" => "UPS Pack & Collect Service 3-Attempt Box 5",
113
+ }
114
+
115
+ TRACKING_STATUS_CODES = HashWithIndifferentAccess.new(
116
+ 'I' => :in_transit,
117
+ 'D' => :delivered,
118
+ 'X' => :exception,
119
+ 'P' => :pickup,
120
+ 'M' => :manifest_pickup
121
+ )
122
+
123
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
124
+ 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)
125
+
126
+ US_TERRITORIES_TREATED_AS_COUNTRIES = %w(AS FM GU MH MP PW PR VI)
127
+
128
+ IMPERIAL_COUNTRIES = %w(US LR MM)
129
+
130
+ DEFAULT_SERVICE_NAME_TO_CODE = Hash[UPS::DEFAULT_SERVICES.to_a.map(&:reverse)]
131
+ DEFAULT_SERVICE_NAME_TO_CODE['UPS 2nd Day Air'] = "02"
132
+ DEFAULT_SERVICE_NAME_TO_CODE['UPS 3 Day Select'] = "12"
133
+ DEFAULT_SERVICE_NAME_TO_CODE['UPS Next Day Air Early'] = "14"
134
+
135
+ SHIPMENT_DELIVERY_CONFIRMATION_CODES = {
136
+ delivery_confirmation_signature_required: 1,
137
+ delivery_confirmation_adult_signature_required: 2
138
+ }
139
+
140
+ PACKAGE_DELIVERY_CONFIRMATION_CODES = {
141
+ delivery_confirmation: 1,
142
+ delivery_confirmation_signature_required: 2,
143
+ delivery_confirmation_adult_signature_required: 3,
144
+ usps_delivery_confirmation: 4
145
+ }
146
+
147
+ def requirements
148
+ [:key, :login, :password]
149
+ end
150
+
151
+ def find_rates(origin, destination, packages, options = {})
152
+ origin, destination = upsified_location(origin), upsified_location(destination)
153
+ options = @options.merge(options)
154
+ packages = Array(packages)
155
+ access_request = build_access_request
156
+ rate_request = build_rate_request(origin, destination, packages, options)
157
+ response = commit(:rates, save_request(access_request + rate_request), options[:test])
158
+ parse_rate_response(origin, destination, packages, response, options)
159
+ end
160
+
161
+ # Retrieves tracking information for a previous shipment
162
+ #
163
+ # @note Override with whatever you need to get a shipping label
164
+ #
165
+ # @param tracking_number [String] The unique identifier of the shipment to track.
166
+ # @param options [Hash] Carrier-specific parameters.
167
+ # @option options [Boolean] :mail_innovations Set this to true to track a Mail Innovations Package
168
+ # @return [ReactiveShipping::TrackingResponse] The response from the carrier. This
169
+ # response should a list of shipment tracking events if successful.
170
+ def find_tracking_info(tracking_number, options = {})
171
+ options = @options.merge(options)
172
+ access_request = build_access_request
173
+ tracking_request = build_tracking_request(tracking_number, options)
174
+ response = commit(:track, save_request(access_request + tracking_request), options[:test])
175
+ parse_tracking_response(response, options)
176
+ end
177
+
178
+ def create_shipment(origin, destination, packages, options = {})
179
+ options = @options.merge(options)
180
+ packages = Array(packages)
181
+ access_request = build_access_request
182
+
183
+ # STEP 1: Confirm. Validation step, important for verifying price.
184
+ confirm_request = build_shipment_request(origin, destination, packages, options)
185
+ logger.debug(confirm_request) if logger
186
+
187
+ confirm_response = commit(:ship_confirm, save_request(access_request + confirm_request), (options[:test] || false))
188
+ logger.debug(confirm_response) if logger
189
+
190
+ # ... now, get the digest, it's needed to get the label. In theory,
191
+ # one could make decisions based on the price or some such to avoid
192
+ # surprises. This also has *no* error handling yet.
193
+ xml = parse_ship_confirm(confirm_response)
194
+ success = response_success?(xml)
195
+ message = response_message(xml)
196
+ raise message unless success
197
+ digest = response_digest(xml)
198
+
199
+ # STEP 2: Accept. Use shipment digest in first response to get the actual label.
200
+ accept_request = build_accept_request(digest, options)
201
+ logger.debug(accept_request) if logger
202
+
203
+ accept_response = commit(:ship_accept, save_request(access_request + accept_request), (options[:test] || false))
204
+ logger.debug(accept_response) if logger
205
+
206
+ # ...finally, build a map from the response that contains
207
+ # the label data and tracking information.
208
+ parse_ship_accept(accept_response)
209
+ end
210
+
211
+ def get_delivery_date_estimates(origin, destination, packages, pickup_date=Date.current, options = {})
212
+ origin, destination = upsified_location(origin), upsified_location(destination)
213
+ options = @options.merge(options)
214
+ packages = Array(packages)
215
+ access_request = build_access_request
216
+ dates_request = build_delivery_dates_request(origin, destination, packages, pickup_date, options)
217
+ response = commit(:delivery_dates, save_request(access_request + dates_request), (options[:test] || false))
218
+ parse_delivery_dates_response(origin, destination, packages, response, options)
219
+ end
220
+
221
+ def void_shipment(tracking, options={})
222
+ options = @options.merge(options)
223
+ access_request = build_access_request
224
+ void_request = build_void_request(tracking)
225
+ response = commit(:void, save_request(access_request + void_request), (options[:test] || false))
226
+ parse_void_response(response, options)
227
+ end
228
+
229
+ def maximum_address_field_length
230
+ # http://www.ups.com/worldshiphelp/WS12/ENU/AppHelp/CONNECT/Shipment_Data_Field_Descriptions.htm
231
+ 35
232
+ end
233
+
234
+ protected
235
+
236
+ def upsified_location(location)
237
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
238
+ atts = {:country => location.state}
239
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
240
+ atts[att] = location.send(att)
241
+ end
242
+ Location.new(atts)
243
+ else
244
+ location
245
+ end
246
+ end
247
+
248
+ def build_access_request
249
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
250
+ xml.AccessRequest do
251
+ xml.AccessLicenseNumber(@options[:key])
252
+ xml.UserId(@options[:login])
253
+ xml.Password(@options[:password])
254
+ end
255
+ end
256
+ xml_builder.to_xml
257
+ end
258
+
259
+ def build_rate_request(origin, destination, packages, options = {})
260
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
261
+ xml.RatingServiceSelectionRequest do
262
+ xml.Request do
263
+ xml.RequestAction('Rate')
264
+ xml.RequestOption((options[:service].nil?) ? 'Shop' : 'Rate')
265
+ end
266
+
267
+ pickup_type = options[:pickup_type] || :daily_pickup
268
+
269
+ xml.PickupType do
270
+ xml.Code(PICKUP_CODES[pickup_type])
271
+ # not implemented: PickupType/PickupDetails element
272
+ end
273
+
274
+ cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
275
+ xml.CustomerClassification do
276
+ xml.Code(CUSTOMER_CLASSIFICATIONS[cc])
277
+ end
278
+
279
+ xml.Shipment do
280
+ # not implemented: Shipment/Description element
281
+ build_location_node(xml, 'Shipper', (options[:shipper] || origin), options)
282
+ build_location_node(xml, 'ShipTo', destination, options)
283
+ build_location_node(xml, 'ShipFrom', origin, options) if options[:shipper] && options[:shipper] != origin
284
+
285
+ # not implemented: * Shipment/ShipmentWeight element
286
+ # * Shipment/ReferenceNumber element
287
+ # * Shipment/Service element
288
+ # * Shipment/PickupDate element
289
+ # * Shipment/ScheduledDeliveryDate element
290
+ # * Shipment/ScheduledDeliveryTime element
291
+ # * Shipment/AlternateDeliveryTime element
292
+ # * Shipment/DocumentsOnly element
293
+
294
+ unless options[:service].nil?
295
+ xml.Service do
296
+ xml.Code(options[:service])
297
+ end
298
+ end
299
+
300
+ Array(packages).each do |package|
301
+ options[:imperial] ||= IMPERIAL_COUNTRIES.include?(origin.country_code(:alpha2))
302
+ build_package_node(xml, package, options)
303
+ end
304
+
305
+ # not implemented: * Shipment/ShipmentServiceOptions element
306
+ if options[:negotiated_rates]
307
+ xml.RateInformation do
308
+ xml.NegotiatedRatesIndicator
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ xml_builder.to_xml
315
+ end
316
+
317
+ # Build XML node to request a shipping label for the given packages.
318
+ #
319
+ # options:
320
+ # * origin_account: who will pay for the shipping label
321
+ # * customer_context: a "guid like substance" -- according to UPS
322
+ # * shipper: who is sending the package and where it should be returned
323
+ # if it is undeliverable.
324
+ # * ship_from: where the package is picked up.
325
+ # * service_code: default to '03'
326
+ # * saturday_delivery: any truthy value causes this element to exist
327
+ # * optional_processing: 'validate' (blank) or 'nonvalidate' or blank
328
+ # * paperless_invoice: set to truthy if using paperless invoice to ship internationally
329
+ # * terms_of_shipment: used with paperless invoice to specify who pays duties and taxes
330
+ # * reference_numbers: Array of hashes with :value => a reference number value and optionally :code => reference number type
331
+ # * prepay: if truthy the shipper will be bill immediatly. Otherwise the shipper is billed when the label is used.
332
+ # * negotiated_rates: if truthy negotiated rates will be requested from ups. Only valid if shipper account has negotiated rates.
333
+ # * delivery_confirmation: Can be set to any key from SHIPMENT_DELIVERY_CONFIRMATION_CODES. Can also be set on package level via package.options
334
+ def build_shipment_request(origin, destination, packages, options={})
335
+ packages = Array(packages)
336
+ shipper = options[:shipper] || origin
337
+ options[:international] = origin.country.name != destination.country.name
338
+ options[:imperial] ||= IMPERIAL_COUNTRIES.include?(shipper.country_code(:alpha2))
339
+ options[:return] = options[:return_service_code].present?
340
+ options[:reason_for_export] ||= ("RETURN" if options[:return])
341
+
342
+ if allow_package_level_reference_numbers(origin, destination)
343
+ if options[:reference_numbers]
344
+ packages.each do |package|
345
+ package.options[:reference_numbers] = options[:reference_numbers]
346
+ end
347
+ end
348
+ options[:reference_numbers] = []
349
+ end
350
+
351
+ handle_delivery_confirmation_options(origin, destination, packages, options)
352
+
353
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
354
+ xml.ShipmentConfirmRequest do
355
+ xml.Request do
356
+ xml.RequestAction('ShipConfirm')
357
+ # Required element cotnrols level of address validation.
358
+ xml.RequestOption(options[:optional_processing] || 'validate')
359
+ # Optional element to identify transactions between client and server.
360
+ if options[:customer_context]
361
+ xml.TransactionReference do
362
+ xml.CustomerContext(options[:customer_context])
363
+ end
364
+ end
365
+ end
366
+
367
+ xml.Shipment do
368
+ xml.Service do
369
+ xml.Code(options[:service_code] || '03')
370
+ end
371
+
372
+ build_location_node(xml, 'ShipTo', destination, options)
373
+ build_location_node(xml, 'ShipFrom', origin, options)
374
+ # Required element. The company whose account is responsible for the label(s).
375
+ build_location_node(xml, 'Shipper', shipper, options)
376
+
377
+ if options[:saturday_delivery]
378
+ xml.ShipmentServiceOptions do
379
+ xml.SaturdayDelivery
380
+ end
381
+ end
382
+
383
+ if options[:origin_account]
384
+ xml.RateInformation do
385
+ xml.NegotiatedRatesIndicator
386
+ end
387
+ end
388
+
389
+ Array(options[:reference_numbers]).each do |reference_num_info|
390
+ xml.ReferenceNumber do
391
+ xml.Code(reference_num_info[:code] || "")
392
+ xml.Value(reference_num_info[:value])
393
+ end
394
+ end
395
+
396
+ if options[:prepay]
397
+ xml.PaymentInformation do
398
+ xml.Prepaid do
399
+ xml.BillShipper do
400
+ xml.AccountNumber(options[:origin_account])
401
+ end
402
+ end
403
+ end
404
+ elsif options[:bill_third_party]
405
+ xml.PaymentInformation do
406
+ xml.BillThirdParty do
407
+ xml.BillThirdPartyShipper do
408
+ xml.AccountNumber(options[:billing_account])
409
+ xml.ThirdParty do
410
+ xml.Address do
411
+ xml.PostalCode(options[:billing_zip])
412
+ xml.CountryCode(options[:billing_country])
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end
418
+ else
419
+ xml.ItemizedPaymentInformation do
420
+ xml.ShipmentCharge do
421
+ # Type '01' means 'Transportation'
422
+ # This node specifies who will be billed for transportation.
423
+ xml.Type('01')
424
+ xml.BillShipper do
425
+ xml.AccountNumber(options[:origin_account])
426
+ end
427
+ end
428
+ if options[:terms_of_shipment] == 'DDP'
429
+ # DDP stands for delivery duty paid and means the shipper will cover duties and taxes
430
+ # Otherwise UPS will charge the receiver
431
+ xml.ShipmentCharge do
432
+ xml.Type('02') # Type '02' means 'Duties and Taxes'
433
+ xml.BillShipper do
434
+ xml.AccountNumber(options[:origin_account])
435
+ end
436
+ end
437
+ end
438
+ end
439
+ end
440
+
441
+ if options[:international]
442
+ unless options[:return]
443
+ build_location_node(xml, 'SoldTo', options[:sold_to] || destination, options)
444
+ end
445
+
446
+ if origin.country_code(:alpha2) == 'US' && ['CA', 'PR'].include?(destination.country_code(:alpha2))
447
+ # Required for shipments from the US to Puerto Rico or Canada
448
+ xml.InvoiceLineTotal do
449
+ total_value = packages.inject(0) {|sum, package| sum + (package.value || 0)}
450
+ xml.MonetaryValue(total_value)
451
+ end
452
+ end
453
+
454
+ contents_description = packages.map {|p| p.options[:description]}.compact.join(',')
455
+ unless contents_description.empty?
456
+ xml.Description(contents_description)
457
+ end
458
+ end
459
+
460
+ if options[:return]
461
+ xml.ReturnService do
462
+ xml.Code(options[:return_service_code])
463
+ end
464
+ end
465
+
466
+ xml.ShipmentServiceOptions do
467
+ if delivery_confirmation = options[:delivery_confirmation]
468
+ xml.DeliveryConfirmation do
469
+ xml.DCISType(SHIPMENT_DELIVERY_CONFIRMATION_CODES[delivery_confirmation])
470
+ end
471
+ end
472
+
473
+ if options[:international]
474
+ build_international_forms(xml, origin, destination, packages, options)
475
+ end
476
+ end
477
+
478
+ # A request may specify multiple packages.
479
+ packages.each do |package|
480
+ build_package_node(xml, package, options)
481
+ end
482
+ end
483
+
484
+ # Supported label formats:
485
+ # GIF, EPL, ZPL, STARPL and SPL
486
+ label_format = options[:label_format] ? options[:label_format].upcase : 'GIF'
487
+ label_size = options[:label_size] ? options[:label_size] : [4, 6]
488
+
489
+ xml.LabelSpecification do
490
+ xml.LabelStockSize do
491
+ xml.Height(label_size[0])
492
+ xml.Width(label_size[1])
493
+ end
494
+
495
+ xml.LabelPrintMethod do
496
+ xml.Code(label_format)
497
+ end
498
+
499
+ # API requires these only if returning a GIF formated label
500
+ if label_format == 'GIF'
501
+ xml.HTTPUserAgent('Mozilla/4.5')
502
+ xml.LabelImageFormat(label_format) do
503
+ xml.Code(label_format)
504
+ end
505
+ end
506
+ end
507
+ end
508
+ end
509
+ xml_builder.to_xml
510
+ end
511
+
512
+ def build_delivery_dates_request(origin, destination, packages, pickup_date, options={})
513
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
514
+
515
+ xml.TimeInTransitRequest do
516
+ xml.Request do
517
+ xml.RequestAction('TimeInTransit')
518
+ end
519
+
520
+ build_address_artifact_format_location(xml, 'TransitFrom', origin)
521
+ build_address_artifact_format_location(xml, 'TransitTo', destination)
522
+
523
+ xml.ShipmentWeight do
524
+ xml.UnitOfMeasurement do
525
+ xml.Code(options[:imperial] ? 'LBS' : 'KGS')
526
+ end
527
+
528
+ value = packages.inject(0) do |sum, package|
529
+ sum + (options[:imperial] ? package.lbs.to_f : package.kgs.to_f )
530
+ end
531
+
532
+ xml.Weight([value.round(3), 0.1].max)
533
+ end
534
+
535
+ xml.InvoiceLineTotal do
536
+ xml.CurrencyCode('USD')
537
+ total_value = packages.inject(0) {|sum, package| sum + package.value}
538
+ xml.MonetaryValue(total_value)
539
+ end
540
+
541
+ xml.PickupDate(pickup_date.strftime('%Y%m%d'))
542
+ end
543
+ end
544
+
545
+ xml_builder.to_xml
546
+ end
547
+
548
+ def build_void_request(tracking)
549
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
550
+ xml.VoidShipmentRequest do
551
+ xml.Request do
552
+ xml.RequestAction('Void')
553
+ end
554
+ xml.ShipmentIdentificationNumber(tracking)
555
+ end
556
+ end
557
+ xml_builder.to_xml
558
+ end
559
+
560
+ def build_international_forms(xml, origin, destination, packages, options)
561
+ if options[:paperless_invoice]
562
+ xml.InternationalForms do
563
+ xml.FormType('01') # 01 is "Invoice"
564
+ xml.InvoiceDate(options[:invoice_date] || Date.today.strftime('%Y%m%d'))
565
+ xml.ReasonForExport(options[:reason_for_export] || 'SALE')
566
+ xml.CurrencyCode(options[:currency_code] || 'USD')
567
+
568
+ if options[:terms_of_shipment]
569
+ xml.TermsOfShipment(options[:terms_of_shipment])
570
+ end
571
+
572
+ packages.each do |package|
573
+ xml.Product do |xml|
574
+ xml.Description(package.options[:description])
575
+ xml.CommodityCode(package.options[:commodity_code])
576
+ xml.OriginCountryCode(origin.country_code(:alpha2))
577
+ xml.Unit do |xml|
578
+ xml.Value(package.value / (package.options[:item_count] || 1))
579
+ xml.Number((package.options[:item_count] || 1))
580
+ xml.UnitOfMeasurement do |xml|
581
+ # NMB = number. You can specify units in barrels, boxes, etc. Codes are in the api docs.
582
+ xml.Code(package.options[:unit_of_item_count] || 'NMB')
583
+ end
584
+ end
585
+ end
586
+ end
587
+ end
588
+ end
589
+ end
590
+
591
+ def build_accept_request(digest, options = {})
592
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
593
+ xml.ShipmentAcceptRequest do
594
+ xml.Request do
595
+ xml.RequestAction('ShipAccept')
596
+ end
597
+ xml.ShipmentDigest(digest)
598
+ end
599
+ end
600
+ xml_builder.to_xml
601
+ end
602
+
603
+ def build_tracking_request(tracking_number, options = {})
604
+ xml_builder = Nokogiri::XML::Builder.new do |xml|
605
+ xml.TrackRequest do
606
+ xml.TrackingOption(options[:tracking_option]) if options[:tracking_option]
607
+ xml.Request do
608
+ xml.RequestAction('Track')
609
+ xml.RequestOption('1')
610
+ end
611
+ xml.TrackingNumber(tracking_number.to_s)
612
+ xml.TrackingOption('03') if options[:mail_innovations]
613
+ end
614
+ end
615
+ xml_builder.to_xml
616
+ end
617
+
618
+ def build_location_node(xml, name, location, options = {})
619
+ # not implemented: * Shipment/Shipper/Name element
620
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
621
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
622
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
623
+ xml.public_send(name) do
624
+ if shipper_name = (location.name || location.company_name || options[:origin_name])
625
+ xml.Name(shipper_name)
626
+ end
627
+ xml.PhoneNumber(location.phone.gsub(/[^\d]/, '')) unless location.phone.blank?
628
+ xml.FaxNumber(location.fax.gsub(/[^\d]/, '')) unless location.fax.blank?
629
+
630
+ if name == 'Shipper' and (origin_account = options[:origin_account] || @options[:origin_account])
631
+ xml.ShipperNumber(origin_account)
632
+ elsif name == 'ShipTo' and (destination_account = options[:destination_account] || @options[:destination_account])
633
+ xml.ShipperAssignedIdentificationNumber(destination_account)
634
+ end
635
+
636
+ if name = (location.company_name || location.name || options[:origin_name])
637
+ xml.CompanyName(name)
638
+ end
639
+
640
+ if phone = location.phone
641
+ xml.PhoneNumber(phone)
642
+ end
643
+
644
+ if attn = location.name
645
+ xml.AttentionName(attn)
646
+ end
647
+
648
+ xml.Address do
649
+ xml.AddressLine1(location.address1) unless location.address1.blank?
650
+ xml.AddressLine2(location.address2) unless location.address2.blank?
651
+ xml.AddressLine3(location.address3) unless location.address3.blank?
652
+ xml.City(location.city) unless location.city.blank?
653
+ xml.StateProvinceCode(location.province) unless location.province.blank?
654
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
655
+ xml.PostalCode(location.postal_code) unless location.postal_code.blank?
656
+ xml.CountryCode(location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
657
+ xml.ResidentialAddressIndicator(true) unless location.commercial? # the default should be that UPS returns residential rates for destinations that it doesn't know about
658
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
659
+ end
660
+ end
661
+ end
662
+
663
+ def build_address_artifact_format_location(xml, name, location)
664
+ xml.public_send(name) do
665
+ xml.AddressArtifactFormat do
666
+ xml.PoliticalDivision2(location.city)
667
+ xml.PoliticalDivision1(location.province)
668
+ xml.CountryCode(location.country_code(:alpha2))
669
+ xml.PostcodePrimaryLow(location.postal_code)
670
+ xml.ResidentialAddressIndicator(true) unless location.commercial?
671
+ end
672
+ end
673
+ end
674
+
675
+ def build_package_node(xml, package, options = {})
676
+ xml.Package do
677
+ # not implemented: * Shipment/Package/PackagingType element
678
+
679
+ #return requires description
680
+ if options[:return]
681
+ contents_description = package.options[:description]
682
+ xml.Description(contents_description) if contents_description
683
+ end
684
+
685
+ xml.PackagingType do
686
+ xml.Code('02')
687
+ end
688
+
689
+ xml.Dimensions do
690
+ xml.UnitOfMeasurement do
691
+ xml.Code(options[:imperial] ? 'IN' : 'CM')
692
+ end
693
+ [:length, :width, :height].each do |axis|
694
+ value = ((options[:imperial] ? package.inches(axis) : package.cm(axis)).to_f * 1000).round / 1000.0 # 3 decimals
695
+ xml.public_send(axis.to_s.capitalize, [value, 0.1].max)
696
+ end
697
+ end
698
+
699
+ xml.PackageWeight do
700
+ if (options[:service] || options[:service_code]) == DEFAULT_SERVICE_NAME_TO_CODE["UPS SurePost (USPS) < 1lb"]
701
+ # SurePost < 1lb uses OZS, not LBS
702
+ code = options[:imperial] ? 'OZS' : 'KGS'
703
+ weight = options[:imperial] ? package.oz : package.kgs
704
+ else
705
+ code = options[:imperial] ? 'LBS' : 'KGS'
706
+ weight = options[:imperial] ? package.lbs : package.kgs
707
+ end
708
+ xml.UnitOfMeasurement do
709
+ xml.Code(code)
710
+ end
711
+
712
+ value = ((weight).to_f * 1000).round / 1000.0 # 3 decimals
713
+ xml.Weight([value, 0.1].max)
714
+ end
715
+
716
+
717
+ Array(package.options[:reference_numbers]).each do |reference_number_info|
718
+ xml.ReferenceNumber do
719
+ xml.Code(reference_number_info[:code] || "")
720
+ xml.Value(reference_number_info[:value])
721
+ end
722
+ end
723
+
724
+ xml.PackageServiceOptions do
725
+ if delivery_confirmation = package.options[:delivery_confirmation]
726
+ xml.DeliveryConfirmation do
727
+ xml.DCISType(PACKAGE_DELIVERY_CONFIRMATION_CODES[delivery_confirmation])
728
+ end
729
+ end
730
+
731
+ if dry_ice = package.options[:dry_ice]
732
+ xml.DryIce do
733
+ xml.RegulationSet(dry_ice[:regulation_set] || 'CFR')
734
+ xml.DryIceWeight do
735
+ xml.UnitOfMeasurement do
736
+ xml.Code(options[:imperial] ? 'LBS' : 'KGS')
737
+ end
738
+ # Cannot be more than package weight.
739
+ # Should be more than 0.0.
740
+ # Valid characters are 0-9 and .(Decimal point).
741
+ # Limit to 1 digit after the decimal. The maximum length
742
+ # of the field is 5 including . and can hold up
743
+ # to 1 decimal place.
744
+ xml.Weight(dry_ice[:weight])
745
+ end
746
+ end
747
+ end
748
+ end
749
+
750
+ # not implemented: * Shipment/Package/LargePackageIndicator element
751
+ # * Shipment/Package/AdditionalHandling element
752
+ end
753
+ end
754
+
755
+ def build_document(xml, expected_root_tag)
756
+ document = Nokogiri.XML(xml)
757
+ if document.root.nil? || document.root.name != expected_root_tag
758
+ raise ReactiveShipping::ResponseContentError.new(StandardError.new('Invalid document'), xml)
759
+ end
760
+ document
761
+ rescue Nokogiri::XML::SyntaxError => e
762
+ raise ReactiveShipping::ResponseContentError.new(e, xml)
763
+ end
764
+
765
+ def parse_rate_response(origin, destination, packages, response, options = {})
766
+ xml = build_document(response, 'RatingServiceSelectionResponse')
767
+ success = response_success?(xml)
768
+ message = response_message(xml)
769
+
770
+ if success
771
+ rate_estimates = xml.root.css('> RatedShipment').map do |rated_shipment|
772
+ service_code = rated_shipment.at('Service/Code').text
773
+ days_to_delivery = rated_shipment.at('GuaranteedDaysToDelivery').text.to_i
774
+ days_to_delivery = nil if days_to_delivery == 0
775
+ RateEstimate.new(origin, destination, @@name, service_name_for(origin, service_code),
776
+ :total_price => rated_shipment.at('TotalCharges/MonetaryValue').text.to_f,
777
+ :insurance_price => rated_shipment.at('ServiceOptionsCharges/MonetaryValue').text.to_f,
778
+ :currency => rated_shipment.at('TotalCharges/CurrencyCode').text,
779
+ :service_code => service_code,
780
+ :packages => packages,
781
+ :delivery_range => [timestamp_from_business_day(days_to_delivery)],
782
+ :negotiated_rate => rated_shipment.at('NegotiatedRates/NetSummaryCharges/GrandTotal/MonetaryValue').try(:text).to_f
783
+ )
784
+ end
785
+ end
786
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
787
+ end
788
+
789
+ def parse_tracking_response(response, options = {})
790
+ xml = build_document(response, 'TrackResponse')
791
+ success = response_success?(xml)
792
+ message = response_message(xml)
793
+
794
+ if success
795
+ delivery_signature = nil
796
+ exception_event, scheduled_delivery_date, actual_delivery_date = nil
797
+ delivered, exception = false
798
+ shipment_events = []
799
+
800
+ first_shipment = xml.root.at('Shipment')
801
+ first_package = first_shipment.at('Package')
802
+ tracking_number = first_shipment.at_xpath('ShipmentIdentificationNumber | Package/TrackingNumber').text
803
+
804
+ # Build status hash
805
+ status_nodes = first_package.css('Activity > Status > StatusType')
806
+
807
+ # Prefer a delivery node
808
+ status_node = status_nodes.detect { |x| x.at('Code').text == 'D' }
809
+ status_node ||= status_nodes.first
810
+
811
+ status_code = status_node.at('Code').text
812
+ status_description = status_node.at('Description').text
813
+ status = TRACKING_STATUS_CODES[status_code]
814
+
815
+ if status_description =~ /out.*delivery/i
816
+ status = :out_for_delivery
817
+ end
818
+
819
+ origin, destination = %w(Shipper ShipTo).map do |location|
820
+ location_from_address_node(first_shipment.at("#{location}/Address"))
821
+ end
822
+
823
+ # Get scheduled delivery date
824
+ unless status == :delivered
825
+ scheduled_delivery_date_node = first_shipment.at('ScheduledDeliveryDate')
826
+ scheduled_delivery_date_node ||= first_shipment.at('RescheduledDeliveryDate')
827
+
828
+ if scheduled_delivery_date_node
829
+ scheduled_delivery_date = parse_ups_datetime(
830
+ :date => scheduled_delivery_date_node,
831
+ :time => nil
832
+ )
833
+ end
834
+ end
835
+
836
+ activities = first_package.css('> Activity')
837
+ unless activities.empty?
838
+ shipment_events = activities.map do |activity|
839
+ description = activity.at('Status/StatusType/Description').text
840
+ type_code = activity.at('Status/StatusType/Code').text
841
+ zoneless_time = parse_ups_datetime(:time => activity.at('Time'), :date => activity.at('Date'))
842
+ location = location_from_address_node(activity.at('ActivityLocation/Address'))
843
+ ShipmentEvent.new(description, zoneless_time, location, nil, type_code)
844
+ end
845
+
846
+ shipment_events = shipment_events.sort_by(&:time)
847
+
848
+ # UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
849
+ # event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
850
+ # This adds an origin event to the shipment activity in such cases.
851
+ if origin && !(shipment_events.count == 1 && status == :delivered)
852
+ first_event = shipment_events[0]
853
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin, first_event.message, first_event.type_code)
854
+
855
+ if within_same_area?(origin, first_event.location)
856
+ shipment_events[0] = origin_event
857
+ else
858
+ shipment_events.unshift(origin_event)
859
+ end
860
+ end
861
+
862
+ # Has the shipment been delivered?
863
+ if status == :delivered
864
+ delivered_activity = activities.first
865
+ delivery_signature = delivered_activity.at('ActivityLocation/SignedForByName').try(:text)
866
+ if delivered_activity.at('Status/StatusType/Code').text == 'D'
867
+ actual_delivery_date = parse_ups_datetime(:date => delivered_activity.at('Date'), :time => delivered_activity.at('Time'))
868
+ end
869
+ unless destination
870
+ destination = shipment_events[-1].location
871
+ end
872
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination, shipment_events.last.message, shipment_events.last.type_code)
873
+ end
874
+ end
875
+
876
+ end
877
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
878
+ :carrier => @@name,
879
+ :xml => response,
880
+ :request => last_request,
881
+ :status => status,
882
+ :status_code => status_code,
883
+ :status_description => status_description,
884
+ :delivery_signature => delivery_signature,
885
+ :scheduled_delivery_date => scheduled_delivery_date,
886
+ :actual_delivery_date => actual_delivery_date,
887
+ :shipment_events => shipment_events,
888
+ :delivered => delivered,
889
+ :exception => exception,
890
+ :exception_event => exception_event,
891
+ :origin => origin,
892
+ :destination => destination,
893
+ :tracking_number => tracking_number)
894
+ end
895
+
896
+ def parse_delivery_dates_response(origin, destination, packages, response, options={})
897
+ xml = build_document(response, 'TimeInTransitResponse')
898
+ success = response_success?(xml)
899
+ message = response_message(xml)
900
+ delivery_estimates = []
901
+
902
+ if success
903
+ xml.css('ServiceSummary').each do |service_summary|
904
+ # Translate the Time in Transit Codes to the service codes used elsewhere
905
+ service_name = service_summary.at('Service/Description').text
906
+ service_code = UPS::DEFAULT_SERVICE_NAME_TO_CODE[service_name]
907
+ date = Date.strptime(service_summary.at('EstimatedArrival/Date').text, '%Y-%m-%d')
908
+ business_transit_days = service_summary.at('EstimatedArrival/BusinessTransitDays').text.to_i
909
+ delivery_estimates << DeliveryDateEstimate.new(origin, destination, self.class.class_variable_get(:@@name),
910
+ service_name,
911
+ :service_code => service_code,
912
+ :guaranteed => service_summary.at('Guaranteed/Code').text == 'Y',
913
+ :date => date,
914
+ :business_transit_days => business_transit_days)
915
+ end
916
+ end
917
+ response = DeliveryDateEstimatesResponse.new(success, message, Hash.from_xml(response).values.first, :delivery_estimates => delivery_estimates, :xml => response, :request => last_request)
918
+ end
919
+
920
+ def parse_void_response(response, options={})
921
+ xml = build_document(response, 'VoidShipmentResponse')
922
+ success = response_success?(xml)
923
+ message = response_message(xml)
924
+ if success
925
+ true
926
+ else
927
+ raise ResponseError.new("Void shipment failed with message: #{message}")
928
+ end
929
+ end
930
+
931
+ def location_from_address_node(address)
932
+ return nil unless address
933
+ country = address.at('CountryCode').try(:text)
934
+ country = 'US' if country == 'ZZ' # Sometimes returned by SUREPOST in the US
935
+ Location.new(
936
+ :country => country,
937
+ :postal_code => address.at('PostalCode').try(:text),
938
+ :province => address.at('StateProvinceCode').try(:text),
939
+ :city => address.at('City').try(:text),
940
+ :address1 => address.at('AddressLine1').try(:text),
941
+ :address2 => address.at('AddressLine2').try(:text),
942
+ :address3 => address.at('AddressLine3').try(:text)
943
+ )
944
+ end
945
+
946
+ def parse_ups_datetime(options = {})
947
+ time, date = options[:time].try(:text), options[:date].text
948
+ if time.nil?
949
+ hour, minute, second = 0
950
+ else
951
+ hour, minute, second = time.scan(/\d{2}/)
952
+ end
953
+ year, month, day = date[0..3], date[4..5], date[6..7]
954
+
955
+ Time.utc(year, month, day, hour, minute, second)
956
+ end
957
+
958
+ def response_success?(document)
959
+ document.root.at('Response/ResponseStatusCode').text == '1'
960
+ end
961
+
962
+ def response_message(document)
963
+ status = document.root.at_xpath('Response/ResponseStatusDescription').try(:text)
964
+ desc = document.root.at_xpath('Response/Error/ErrorDescription').try(:text)
965
+ [status, desc].select(&:present?).join(": ").presence || "UPS could not process the request."
966
+ end
967
+
968
+ def response_digest(xml)
969
+ xml.root.at('ShipmentDigest').text
970
+ end
971
+
972
+ def parse_ship_confirm(response)
973
+ build_document(response, 'ShipmentConfirmResponse')
974
+ end
975
+
976
+ def parse_ship_accept(response)
977
+ xml = build_document(response, 'ShipmentAcceptResponse')
978
+ success = response_success?(xml)
979
+ message = response_message(xml)
980
+
981
+ response_info = Hash.from_xml(response).values.first
982
+ packages = response_info["ShipmentResults"]["PackageResults"]
983
+ packages = [packages] if Hash === packages
984
+ labels = packages.map do |package|
985
+ Label.new(package["TrackingNumber"], Base64.decode64(package["LabelImage"]["GraphicImage"]))
986
+ end
987
+
988
+ LabelResponse.new(success, message, response_info, {labels: labels})
989
+ end
990
+
991
+ def commit(action, request, test = false)
992
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
993
+ end
994
+
995
+ def within_same_area?(origin, location)
996
+ return false unless location
997
+ matching_country_codes = origin.country_code(:alpha2) == location.country_code(:alpha2)
998
+ matching_or_blank_city = location.city.blank? || location.city == origin.city
999
+ matching_country_codes && matching_or_blank_city
1000
+ end
1001
+
1002
+ def service_name_for(origin, code)
1003
+ origin = origin.country_code(:alpha2)
1004
+
1005
+ name = case origin
1006
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
1007
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
1008
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
1009
+ end
1010
+
1011
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
1012
+ name || DEFAULT_SERVICES[code]
1013
+ end
1014
+
1015
+ def allow_package_level_reference_numbers(origin, destination)
1016
+ # if the package is US -> US or PR -> PR the only type of reference numbers that are allowed are package-level
1017
+ # Otherwise the only type of reference numbers that are allowed are shipment-level
1018
+ [['US','US'],['PR', 'PR']].include?([origin,destination].map(&:country_code))
1019
+ end
1020
+
1021
+ def handle_delivery_confirmation_options(origin, destination, packages, options)
1022
+ if package_level_delivery_confirmation?(origin, destination)
1023
+ handle_package_level_delivery_confirmation(origin, destination, packages, options)
1024
+ else
1025
+ handle_shipment_level_delivery_confirmation(origin, destination, packages, options)
1026
+ end
1027
+ end
1028
+
1029
+ def handle_package_level_delivery_confirmation(origin, destination, packages, options)
1030
+ packages.each do |package|
1031
+ # Transfer shipment-level option to package with no specified delivery_confirmation
1032
+ package.options[:delivery_confirmation] = options[:delivery_confirmation] unless package.options[:delivery_confirmation]
1033
+
1034
+ # Assert that option is valid
1035
+ if package.options[:delivery_confirmation] && !PACKAGE_DELIVERY_CONFIRMATION_CODES[package.options[:delivery_confirmation]]
1036
+ raise "Invalid delivery_confirmation option on package: '#{package.options[:delivery_confirmation]}'. Use a key from PACKAGE_DELIVERY_CONFIRMATION_CODES"
1037
+ end
1038
+ end
1039
+ options.delete(:delivery_confirmation)
1040
+ end
1041
+
1042
+ def handle_shipment_level_delivery_confirmation(origin, destination, packages, options)
1043
+ if packages.any? { |p| p.options[:delivery_confirmation] }
1044
+ raise "origin/destination pair does not support package level delivery_confirmation options"
1045
+ end
1046
+
1047
+ if options[:delivery_confirmation] && !SHIPMENT_DELIVERY_CONFIRMATION_CODES[options[:delivery_confirmation]]
1048
+ raise "Invalid delivery_confirmation option: '#{options[:delivery_confirmation]}'. Use a key from SHIPMENT_DELIVERY_CONFIRMATION_CODES"
1049
+ end
1050
+ end
1051
+
1052
+ # For certain origin/destination pairs, UPS allows each package in a shipment to have a specified delivery_confirmation option
1053
+ # otherwise the delivery_confirmation option must be specified on the entire shipment.
1054
+ # See Appendix P of UPS Shipping Package XML Developers Guide for the rules on which the logic below is based.
1055
+ def package_level_delivery_confirmation?(origin, destination)
1056
+ origin.country_code == destination.country_code ||
1057
+ [['US','PR'], ['PR','US']].include?([origin,destination].map(&:country_code))
1058
+ end
1059
+ end
1060
+ end