active_shipping 0.0.1

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 (81) hide show
  1. data/.gitignore +6 -0
  2. data/CHANGELOG +23 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.markdown +173 -0
  5. data/Rakefile +55 -0
  6. data/VERSION +1 -0
  7. data/init.rb +1 -0
  8. data/lib/active_shipping.rb +50 -0
  9. data/lib/active_shipping/lib/connection.rb +170 -0
  10. data/lib/active_shipping/lib/country.rb +319 -0
  11. data/lib/active_shipping/lib/error.rb +4 -0
  12. data/lib/active_shipping/lib/post_data.rb +22 -0
  13. data/lib/active_shipping/lib/posts_data.rb +47 -0
  14. data/lib/active_shipping/lib/requires_parameters.rb +16 -0
  15. data/lib/active_shipping/lib/utils.rb +18 -0
  16. data/lib/active_shipping/lib/validateable.rb +76 -0
  17. data/lib/active_shipping/shipping/base.rb +15 -0
  18. data/lib/active_shipping/shipping/carrier.rb +75 -0
  19. data/lib/active_shipping/shipping/carriers.rb +17 -0
  20. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +16 -0
  21. data/lib/active_shipping/shipping/carriers/fedex.rb +315 -0
  22. data/lib/active_shipping/shipping/carriers/shipwire.rb +167 -0
  23. data/lib/active_shipping/shipping/carriers/ups.rb +368 -0
  24. data/lib/active_shipping/shipping/carriers/usps.rb +496 -0
  25. data/lib/active_shipping/shipping/location.rb +100 -0
  26. data/lib/active_shipping/shipping/package.rb +144 -0
  27. data/lib/active_shipping/shipping/rate_estimate.rb +54 -0
  28. data/lib/active_shipping/shipping/rate_response.rb +19 -0
  29. data/lib/active_shipping/shipping/response.rb +49 -0
  30. data/lib/active_shipping/shipping/shipment_event.rb +14 -0
  31. data/lib/active_shipping/shipping/tracking_response.rb +22 -0
  32. data/lib/certs/cacert.pem +7815 -0
  33. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  34. data/lib/vendor/quantified/README.markdown +49 -0
  35. data/lib/vendor/quantified/Rakefile +21 -0
  36. data/lib/vendor/quantified/init.rb +0 -0
  37. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  38. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  39. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  40. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  41. data/lib/vendor/quantified/test/length_test.rb +92 -0
  42. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  43. data/lib/vendor/quantified/test/test_helper.rb +2 -0
  44. data/lib/vendor/test_helper.rb +13 -0
  45. data/lib/vendor/xml_node/README +36 -0
  46. data/lib/vendor/xml_node/Rakefile +21 -0
  47. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  48. data/lib/vendor/xml_node/init.rb +1 -0
  49. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  50. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  51. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  52. data/test/fixtures.yml +13 -0
  53. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_request.xml +67 -0
  54. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_response.xml +213 -0
  55. data/test/fixtures/xml/fedex/tracking_request.xml +27 -0
  56. data/test/fixtures/xml/fedex/tracking_response.xml +153 -0
  57. data/test/fixtures/xml/shipwire/international_rates_response.xml +17 -0
  58. data/test/fixtures/xml/shipwire/invalid_credentials_response.xml +4 -0
  59. data/test/fixtures/xml/shipwire/new_carrier_rate_response.xml +18 -0
  60. data/test/fixtures/xml/shipwire/no_rates_response.xml +7 -0
  61. data/test/fixtures/xml/shipwire/rates_response.xml +36 -0
  62. data/test/fixtures/xml/ups/example_tracking_response.xml +53 -0
  63. data/test/fixtures/xml/ups/shipment_from_tiger_direct.xml +222 -0
  64. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_response.xml +1 -0
  65. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_book_rate_response.xml +85 -0
  66. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_book_wii_rate_response.xml +168 -0
  67. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_wii_rate_response.xml +85 -0
  68. data/test/remote/fedex_test.rb +140 -0
  69. data/test/remote/shipwire_test.rb +88 -0
  70. data/test/remote/ups_test.rb +187 -0
  71. data/test/remote/usps_test.rb +184 -0
  72. data/test/test_helper.rb +167 -0
  73. data/test/unit/base_test.rb +18 -0
  74. data/test/unit/carriers/fedex_test.rb +78 -0
  75. data/test/unit/carriers/shipwire_test.rb +130 -0
  76. data/test/unit/carriers/ups_test.rb +81 -0
  77. data/test/unit/carriers/usps_test.rb +206 -0
  78. data/test/unit/location_test.rb +46 -0
  79. data/test/unit/package_test.rb +65 -0
  80. data/test/unit/response_test.rb +10 -0
  81. metadata +158 -0
@@ -0,0 +1,368 @@
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://www.ups.com'
14
+
15
+ RESOURCES = {
16
+ :rates => 'ups.app/xml/Rate',
17
+ :track => 'ups.app/xml/Track'
18
+ }
19
+
20
+ PICKUP_CODES = {
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
+ DEFAULT_SERVICES = {
31
+ "01" => "UPS Next Day Air",
32
+ "02" => "UPS Second Day Air",
33
+ "03" => "UPS Ground",
34
+ "07" => "UPS Worldwide Express",
35
+ "08" => "UPS Worldwide Expedited",
36
+ "11" => "UPS Standard",
37
+ "12" => "UPS Three-Day Select",
38
+ "13" => "UPS Next Day Air Saver",
39
+ "14" => "UPS Next Day Air Early A.M.",
40
+ "54" => "UPS Worldwide Express Plus",
41
+ "59" => "UPS Second Day Air A.M.",
42
+ "65" => "UPS Saver",
43
+ "82" => "UPS Today Standard",
44
+ "83" => "UPS Today Dedicated Courier",
45
+ "84" => "UPS Today Intercity",
46
+ "85" => "UPS Today Express",
47
+ "86" => "UPS Today Express Saver"
48
+ }
49
+
50
+ CANADA_ORIGIN_SERVICES = {
51
+ "01" => "UPS Express",
52
+ "02" => "UPS Expedited",
53
+ "14" => "UPS Express Early A.M."
54
+ }
55
+
56
+ MEXICO_ORIGIN_SERVICES = {
57
+ "07" => "UPS Express",
58
+ "08" => "UPS Expedited",
59
+ "54" => "UPS Express Plus"
60
+ }
61
+
62
+ EU_ORIGIN_SERVICES = {
63
+ "07" => "UPS Express",
64
+ "08" => "UPS Expedited"
65
+ }
66
+
67
+ OTHER_NON_US_ORIGIN_SERVICES = {
68
+ "07" => "UPS Express"
69
+ }
70
+
71
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
72
+ 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"]
73
+
74
+ US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
75
+
76
+ def requirements
77
+ [:key, :login, :password]
78
+ end
79
+
80
+ def find_rates(origin, destination, packages, options={})
81
+ origin, destination = upsified_location(origin), upsified_location(destination)
82
+ options = @options.merge(options)
83
+ packages = Array(packages)
84
+ access_request = build_access_request
85
+ rate_request = build_rate_request(origin, destination, packages, options)
86
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
87
+ parse_rate_response(origin, destination, packages, response, options)
88
+ end
89
+
90
+ def find_tracking_info(tracking_number, options={})
91
+ options = @options.update(options)
92
+ access_request = build_access_request
93
+ tracking_request = build_tracking_request(tracking_number, options)
94
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
95
+ parse_tracking_response(response, options)
96
+ end
97
+
98
+ protected
99
+
100
+ def upsified_location(location)
101
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
102
+ atts = {:country => location.state}
103
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
104
+ atts[att] = location.send(att)
105
+ end
106
+ Location.new(atts)
107
+ else
108
+ location
109
+ end
110
+ end
111
+
112
+ def build_access_request
113
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
114
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
115
+ access_request << XmlNode.new('UserId', @options[:login])
116
+ access_request << XmlNode.new('Password', @options[:password])
117
+ end
118
+ xml_request.to_s
119
+ end
120
+
121
+ def build_rate_request(origin, destination, packages, options={})
122
+ packages = Array(packages)
123
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
124
+ root_node << XmlNode.new('Request') do |request|
125
+ request << XmlNode.new('RequestAction', 'Rate')
126
+ request << XmlNode.new('RequestOption', 'Shop')
127
+ # not implemented: 'Rate' RequestOption to specify a single service query
128
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
129
+ end
130
+ root_node << XmlNode.new('PickupType') do |pickup_type|
131
+ pickup_type << XmlNode.new('Code', PICKUP_CODES[options[:pickup_type] || :daily_pickup])
132
+ # not implemented: PickupType/PickupDetails element
133
+ end
134
+ # not implemented: CustomerClassification element
135
+ root_node << XmlNode.new('Shipment') do |shipment|
136
+ # not implemented: Shipment/Description element
137
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
138
+ shipment << build_location_node('ShipTo', destination, options)
139
+ if options[:shipper] and options[:shipper] != origin
140
+ shipment << build_location_node('ShipFrom', origin, options)
141
+ end
142
+
143
+ # not implemented: * Shipment/ShipmentWeight element
144
+ # * Shipment/ReferenceNumber element
145
+ # * Shipment/Service element
146
+ # * Shipment/PickupDate element
147
+ # * Shipment/ScheduledDeliveryDate element
148
+ # * Shipment/ScheduledDeliveryTime element
149
+ # * Shipment/AlternateDeliveryTime element
150
+ # * Shipment/DocumentsOnly element
151
+
152
+ packages.each do |package|
153
+ debugger if package.nil?
154
+
155
+
156
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
157
+
158
+ shipment << XmlNode.new("Package") do |package_node|
159
+
160
+ # not implemented: * Shipment/Package/PackagingType element
161
+ # * Shipment/Package/Description element
162
+
163
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
164
+ packaging_type << XmlNode.new("Code", '02')
165
+ end
166
+
167
+ package_node << XmlNode.new("Dimensions") do |dimensions|
168
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
169
+ units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
170
+ end
171
+ [:length,:width,:height].each do |axis|
172
+ value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
173
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
174
+ end
175
+ end
176
+
177
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
178
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
179
+ units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
180
+ end
181
+
182
+ value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
183
+ package_weight << XmlNode.new("Weight", [value,0.1].max)
184
+ end
185
+
186
+ # not implemented: * Shipment/Package/LargePackageIndicator element
187
+ # * Shipment/Package/ReferenceNumber element
188
+ # * Shipment/Package/PackageServiceOptions element
189
+ # * Shipment/Package/AdditionalHandling element
190
+ end
191
+
192
+ end
193
+
194
+ # not implemented: * Shipment/ShipmentServiceOptions element
195
+ # * Shipment/RateInformation element
196
+
197
+ end
198
+
199
+ end
200
+ xml_request.to_s
201
+ end
202
+
203
+ def build_tracking_request(tracking_number, options={})
204
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
205
+ root_node << XmlNode.new('Request') do |request|
206
+ request << XmlNode.new('RequestAction', 'Track')
207
+ request << XmlNode.new('RequestOption', '1')
208
+ end
209
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
210
+ end
211
+ xml_request.to_s
212
+ end
213
+
214
+ def build_location_node(name,location,options={})
215
+ # not implemented: * Shipment/Shipper/Name element
216
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
217
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
218
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
219
+ location_node = XmlNode.new(name) do |location_node|
220
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
221
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
222
+
223
+ if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
224
+ location_node << XmlNode.new('ShipperNumber', origin_account)
225
+ elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
226
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
227
+ end
228
+
229
+ location_node << XmlNode.new('Address') do |address|
230
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
231
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
232
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
233
+ address << XmlNode.new("City", location.city) unless location.city.blank?
234
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
235
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
236
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
237
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
238
+ 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
239
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
240
+ end
241
+ end
242
+ end
243
+
244
+ def parse_rate_response(origin, destination, packages, response, options={})
245
+ rates = []
246
+
247
+ xml = REXML::Document.new(response)
248
+ success = response_success?(xml)
249
+ message = response_message(xml)
250
+
251
+ if success
252
+ rate_estimates = []
253
+
254
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
255
+ service_code = rated_shipment.get_text('Service/Code').to_s
256
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
257
+ service_name_for(origin, service_code),
258
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
259
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
260
+ :service_code => service_code,
261
+ :packages => packages)
262
+ end
263
+ end
264
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
265
+ end
266
+
267
+ def parse_tracking_response(response, options={})
268
+ xml = REXML::Document.new(response)
269
+ success = response_success?(xml)
270
+ message = response_message(xml)
271
+
272
+ if success
273
+ tracking_number, origin, destination = nil
274
+ shipment_events = []
275
+
276
+ first_shipment = xml.elements['/*/Shipment']
277
+ first_package = first_shipment.elements['Package']
278
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
279
+
280
+ origin, destination = %w{Shipper ShipTo}.map do |location|
281
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
282
+ end
283
+
284
+ activities = first_package.get_elements('Activity')
285
+ unless activities.empty?
286
+ shipment_events = activities.map do |activity|
287
+ description = activity.get_text('Status/StatusType/Description').to_s
288
+ zoneless_time = if (time = activity.get_text('Time')) &&
289
+ (date = activity.get_text('Date'))
290
+ time, date = time.to_s, date.to_s
291
+ hour, minute, second = time.scan(/\d{2}/)
292
+ year, month, day = date[0..3], date[4..5], date[6..7]
293
+ Time.utc(year, month, day, hour, minute, second)
294
+ end
295
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
296
+ ShipmentEvent.new(description, zoneless_time, location)
297
+ end
298
+
299
+ shipment_events = shipment_events.sort_by(&:time)
300
+
301
+ if origin
302
+ first_event = shipment_events[0]
303
+ same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
304
+ same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
305
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
306
+ if same_country and same_or_blank_city
307
+ shipment_events[0] = origin_event
308
+ else
309
+ shipment_events.unshift(origin_event)
310
+ end
311
+ end
312
+ if shipment_events.last.name.downcase == 'delivered'
313
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
314
+ end
315
+ end
316
+
317
+ end
318
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
319
+ :xml => response,
320
+ :request => last_request,
321
+ :shipment_events => shipment_events,
322
+ :origin => origin,
323
+ :destination => destination,
324
+ :tracking_number => tracking_number)
325
+ end
326
+
327
+ def location_from_address_node(address)
328
+ return nil unless address
329
+ Location.new(
330
+ :country => node_text_or_nil(address.elements['CountryCode']),
331
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
332
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
333
+ :city => node_text_or_nil(address.elements['City']),
334
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
335
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
336
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
337
+ )
338
+ end
339
+
340
+ def response_success?(xml)
341
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
342
+ end
343
+
344
+ def response_message(xml)
345
+ xml.get_text('/*/Response/ResponseStatusDescription | /*/Response/Error/ErrorDescription').to_s
346
+ end
347
+
348
+ def commit(action, request, test = false)
349
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
350
+ end
351
+
352
+
353
+ def service_name_for(origin, code)
354
+ origin = origin.country_code(:alpha2)
355
+
356
+ name = case origin
357
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
358
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
359
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
360
+ end
361
+
362
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
363
+ name ||= DEFAULT_SERVICES[code]
364
+ end
365
+
366
+ end
367
+ end
368
+ end
@@ -0,0 +1,496 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require 'cgi'
3
+
4
+ module ActiveMerchant
5
+ module Shipping
6
+
7
+ # After getting an API login from USPS (looks like '123YOURNAME456'),
8
+ # run the following test:
9
+ #
10
+ # usps = USPS.new(:login => '123YOURNAME456', :test => true)
11
+ # usps.valid_credentials?
12
+ #
13
+ # This will send a test request to the USPS test servers, which they ask you
14
+ # to do before they put your API key in production mode.
15
+ class USPS < Carrier
16
+ self.retry_safe = true
17
+
18
+ cattr_reader :name
19
+ @@name = "USPS"
20
+
21
+ LIVE_DOMAIN = 'production.shippingapis.com'
22
+ LIVE_RESOURCE = 'ShippingAPI.dll'
23
+
24
+ TEST_DOMAINS = { #indexed by security; e.g. TEST_DOMAINS[USE_SSL[:rates]]
25
+ true => 'secure.shippingapis.com',
26
+ false => 'testing.shippingapis.com'
27
+ }
28
+
29
+ TEST_RESOURCE = 'ShippingAPITest.dll'
30
+
31
+ API_CODES = {
32
+ :us_rates => 'RateV3',
33
+ :track => 'TrackV2',
34
+ :world_rates => 'IntlRate',
35
+ :test => 'CarrierPickupAvailability'
36
+ }
37
+ USE_SSL = {
38
+ :us_rates => false,
39
+ :world_rates => false,
40
+ :track => false,
41
+ :test => true
42
+ }
43
+ CONTAINERS = {
44
+ :envelope => 'Flat Rate Envelope',
45
+ :box => 'Flat Rate Box'
46
+ }
47
+ MAIL_TYPES = {
48
+ :package => 'Package',
49
+ :postcard => 'Postcards or aerogrammes',
50
+ :matter_for_the_blind => 'Matter for the blind',
51
+ :envelope => 'Envelope'
52
+ }
53
+ PACKAGE_PROPERTIES = {
54
+ 'ZipOrigination' => :origin_zip,
55
+ 'ZipDestination' => :destination_zip,
56
+ 'Pounds' => :pounds,
57
+ 'Ounces' => :ounces,
58
+ 'Container' => :container,
59
+ 'Size' => :size,
60
+ 'Machinable' => :machinable,
61
+ 'Zone' => :zone,
62
+ 'Postage' => :postage,
63
+ 'Restrictions' => :restrictions
64
+ }
65
+ POSTAGE_PROPERTIES = {
66
+ 'MailService' => :service,
67
+ 'Rate' => :rate
68
+ }
69
+ US_SERVICES = {
70
+ :first_class => 'FIRST CLASS',
71
+ :priority => 'PRIORITY',
72
+ :express => 'EXPRESS',
73
+ :bpm => 'BPM',
74
+ :parcel => 'PARCEL',
75
+ :media => 'MEDIA',
76
+ :library => 'LIBRARY',
77
+ :all => 'ALL'
78
+ }
79
+
80
+ # TODO: get rates for "U.S. possessions and Trust Territories" like Guam, etc. via domestic rates API: http://www.usps.com/ncsc/lookups/abbr_state.txt
81
+ # TODO: figure out how USPS likes to say "Ivory Coast"
82
+ #
83
+ # Country names:
84
+ # http://pe.usps.gov/text/Imm/immctry.htm
85
+ COUNTRY_NAME_CONVERSIONS = {
86
+ "BA" => "Bosnia-Herzegovina",
87
+ "CD" => "Congo, Democratic Republic of the",
88
+ "CG" => "Congo (Brazzaville),Republic of the",
89
+ "CI" => "Côte d'Ivoire (Ivory Coast)",
90
+ "CK" => "Cook Islands (New Zealand)",
91
+ "FK" => "Falkland Islands",
92
+ "GB" => "Great Britain and Northern Ireland",
93
+ "GE" => "Georgia, Republic of",
94
+ "IR" => "Iran",
95
+ "KN" => "Saint Kitts (St. Christopher and Nevis)",
96
+ "KP" => "North Korea (Korea, Democratic People's Republic of)",
97
+ "KR" => "South Korea (Korea, Republic of)",
98
+ "LA" => "Laos",
99
+ "LY" => "Libya",
100
+ "MC" => "Monaco (France)",
101
+ "MD" => "Moldova",
102
+ "MK" => "Macedonia, Republic of",
103
+ "MM" => "Burma",
104
+ "PN" => "Pitcairn Island",
105
+ "RU" => "Russia",
106
+ "SK" => "Slovak Republic",
107
+ "TK" => "Tokelau (Union) Group (Western Samoa)",
108
+ "TW" => "Taiwan",
109
+ "TZ" => "Tanzania",
110
+ "VA" => "Vatican City",
111
+ "VG" => "British Virgin Islands",
112
+ "VN" => "Vietnam",
113
+ "WF" => "Wallis and Futuna Islands",
114
+ "WS" => "Western Samoa"
115
+ }
116
+
117
+ def self.size_code_for(package)
118
+ total = package.inches(:length) + package.inches(:girth)
119
+ if total <= 84
120
+ return 'REGULAR'
121
+ elsif total <= 108
122
+ return 'LARGE'
123
+ else # <= 130
124
+ return 'OVERSIZE'
125
+ end
126
+ end
127
+
128
+ # from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
129
+ #
130
+ # package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
131
+ # Defaults to false.
132
+ def self.package_machinable?(package, options={})
133
+ at_least_minimum = package.inches(:length) >= 6.0 &&
134
+ package.inches(:width) >= 3.0 &&
135
+ package.inches(:height) >= 0.25 &&
136
+ package.ounces >= 6.0
137
+ at_most_maximum = package.inches(:length) <= 34.0 &&
138
+ package.inches(:width) <= 17.0 &&
139
+ package.inches(:height) <= 17.0 &&
140
+ package.pounds <= (package.options[:books] ? 25.0 : 35.0)
141
+ at_least_minimum && at_most_maximum
142
+ end
143
+
144
+ def requirements
145
+ [:login]
146
+ end
147
+
148
+ def find_rates(origin, destination, packages, options = {})
149
+ options = @options.merge(options)
150
+
151
+ origin = Location.from(origin)
152
+ destination = Location.from(destination)
153
+ packages = Array(packages)
154
+
155
+ #raise ArgumentError.new("USPS packages must originate in the U.S.") unless ['US',nil].include?(origin.country_code(:alpha2))
156
+
157
+
158
+ # domestic or international?
159
+
160
+ response = if ['US',nil].include?(destination.country_code(:alpha2))
161
+ us_rates(origin, destination, packages, options)
162
+ else
163
+ world_rates(origin, destination, packages, options)
164
+ end
165
+ end
166
+
167
+ def find_tracking_info(tracking_numbers, options={})
168
+ options = @options.update(options)
169
+ tracking_numbers = [tracking_numbers] unless tracking_numbers.is_a?(Array)
170
+ tracking_request = build_tracking_request(tracking_numbers)
171
+ response = commit(:track, save_request(tracking_request), (options[:test] || false))
172
+ parse_tracking_response(response, options)
173
+ end
174
+
175
+ def valid_credentials?
176
+ # Cannot test with find_rates because USPS doesn't allow that in test mode
177
+ test_mode? ? canned_address_verification_works? : super
178
+ end
179
+
180
+ def maximum_weight
181
+ Mass.new(70, :pounds)
182
+ end
183
+
184
+ protected
185
+
186
+ def us_rates(origin, destination, packages, options={})
187
+ request = build_us_rate_request(packages, origin.zip, destination.zip, options)
188
+ # never use test mode; rate requests just won't work on test servers
189
+ parse_rate_response origin, destination, packages, commit(:us_rates,request,false), options
190
+ end
191
+
192
+ def world_rates(origin, destination, packages, options={})
193
+ request = build_world_rate_request(packages, destination.country)
194
+ # never use test mode; rate requests just won't work on test servers
195
+ parse_rate_response origin, destination, packages, commit(:world_rates,request,false), options
196
+ end
197
+
198
+ # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
199
+ def canned_address_verification_works?
200
+ request = "%3CCarrierPickupAvailabilityRequest%20USERID=%22#{URI.encode(@options[:login])}%22%3E%20%0A%3CFirmName%3EABC%20Corp.%3C/FirmName%3E%20%0A%3CSuiteOrApt%3ESuite%20777%3C/SuiteOrApt%3E%20%0A%3CAddress2%3E1390%20Market%20Street%3C/Address2%3E%20%0A%3CUrbanization%3E%3C/Urbanization%3E%20%0A%3CCity%3EHouston%3C/City%3E%20%0A%3CState%3ETX%3C/State%3E%20%0A%3CZIP5%3E77058%3C/ZIP5%3E%20%0A%3CZIP4%3E1234%3C/ZIP4%3E%20%0A%3C/CarrierPickupAvailabilityRequest%3E%0A"
201
+ # expected_hash = {"CarrierPickupAvailabilityResponse"=>{"City"=>"HOUSTON", "Address2"=>"1390 Market Street", "FirmName"=>"ABC Corp.", "State"=>"TX", "Date"=>"3/1/2004", "DayOfWeek"=>"Monday", "Urbanization"=>nil, "ZIP4"=>"1234", "ZIP5"=>"77058", "CarrierRoute"=>"C", "SuiteOrApt"=>"Suite 777"}}
202
+ xml = REXML::Document.new(commit(:test, request, true))
203
+ xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'HOUSTON' &&
204
+ xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '1390 Market Street'
205
+ end
206
+
207
+ def build_tracking_request(tracking_numbers)
208
+ request = XmlNode.new('TrackFieldRequest', :USERID => @options[:login]) do |tracking_request|
209
+ tracking_numbers.each { |i| tracking_request << XmlNode.new('TrackID', :ID => i) }
210
+ end
211
+
212
+ URI.encode(save_request(request.to_s))
213
+ end
214
+
215
+ # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
216
+ # :media, :library, :all]. defaults to :all.
217
+ # options[:container] -- One of [:envelope, :box]. defaults to neither (this field has
218
+ # special meaning in the USPS API).
219
+ # options[:books] -- Either true or false. Packages of books or other printed matter
220
+ # have a lower weight limit to be considered machinable.
221
+ # package.options[:machinable] -- Either true or false. Overrides the detection of
222
+ # "machinability" entirely.
223
+ def build_us_rate_request(packages, origin_zip, destination_zip, options={})
224
+ packages = Array(packages)
225
+ request = XmlNode.new('RateV3Request', :USERID => @options[:login]) do |rate_request|
226
+ packages.each_with_index do |p,id|
227
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
228
+ package << XmlNode.new('Service', US_SERVICES[options[:service] || :all])
229
+ package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
230
+ package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
231
+ package << XmlNode.new('Pounds', 0)
232
+ package << XmlNode.new('Ounces', "%0.1f" % [p.ounces,1].max)
233
+ if p.options[:container] and [nil,:all,:express,:priority].include? p.service
234
+ package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
235
+ end
236
+ package << XmlNode.new('Size', USPS.size_code_for(p))
237
+ package << XmlNode.new('Width', p.inches(:width))
238
+ package << XmlNode.new('Length', p.inches(:length))
239
+ package << XmlNode.new('Height', p.inches(:height))
240
+ package << XmlNode.new('Girth', p.inches(:girth))
241
+ is_machinable = if p.options.has_key?(:machinable)
242
+ p.options[:machinable] ? true : false
243
+ else
244
+ USPS.package_machinable?(p)
245
+ end
246
+ package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
247
+ end
248
+ end
249
+ end
250
+ URI.encode(save_request(request.to_s))
251
+ end
252
+
253
+ # important difference with international rate requests:
254
+ # * services are not given in the request
255
+ # * package sizes are not given in the request
256
+ # * services are returned in the response along with restrictions of size
257
+ # * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
258
+ #
259
+ #
260
+ # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
261
+ # Defaults to :package.
262
+ def build_world_rate_request(packages, destination_country)
263
+ country = COUNTRY_NAME_CONVERSIONS[destination_country.code(:alpha2).first.value] || destination_country.name
264
+ request = XmlNode.new('IntlRateRequest', :USERID => @options[:login]) do |rate_request|
265
+ packages.each_index do |id|
266
+ p = packages[id]
267
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
268
+ package << XmlNode.new('Pounds', 0)
269
+ package << XmlNode.new('Ounces', [p.ounces,1].max.ceil) #takes an integer for some reason, must be rounded UP
270
+ package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
271
+ package << XmlNode.new('ValueOfContents', p.value / 100.0) if p.value && p.currency == 'USD'
272
+ package << XmlNode.new('Country') do |node|
273
+ node.cdata = country
274
+ end
275
+ end
276
+ end
277
+ end
278
+ URI.encode(save_request(request.to_s))
279
+ end
280
+
281
+ def parse_tracking_response(response, options = {})
282
+ xml = REXML::Document.new(response)
283
+ shipment_events = []
284
+ if success = response_success?(xml)
285
+ info = xml.root.get_elements('TrackInfo')
286
+ unless info.empty?
287
+ responses = info.map do |i|
288
+ tracking_number = i.attributes['ID']
289
+
290
+ track_details = i.get_elements('TrackDetail')
291
+ unless track_details.empty?
292
+ shipment_events = track_details.map do |track_detail|
293
+ name = track_detail.get_text('Event').to_s
294
+ if (time = track_detail.get_text('EventTime')) &&
295
+ (date = track_detail.get_text('EventDate'))
296
+
297
+ event_time = Time.parse("#{date} #{time}")
298
+ end
299
+
300
+ location = Location.new(
301
+ :city => node_string_or_nil(track_detail.elements['EventCity']),
302
+ :state => node_string_or_nil(track_detail.elements['EventState']),
303
+ :postal_code => node_string_or_nil(track_detail.elements['EventZIPCode']),
304
+ :country => node_string_or_nil(track_detail.elements['EventCountry'])
305
+ )
306
+
307
+ ShipmentEvent.new(name, event_time, location)
308
+
309
+ end
310
+ end
311
+ TrackingResponse.new(success, response_message(xml),
312
+ Hash.from_xml(response).values.first,
313
+ :xml => response,
314
+ :request => last_request,
315
+ :shipment_events => shipment_events,
316
+ :tracking_number => tracking_number
317
+ )
318
+ end
319
+ end
320
+ end
321
+ end
322
+
323
+
324
+
325
+ def response_success?(xml)
326
+ xml.elements['/Error'].nil?
327
+ end
328
+
329
+ def response_message(xml)
330
+ if error = xml.elements['/Error']
331
+ message = error.elements['Description'].text
332
+ else
333
+ message = ''
334
+ end
335
+
336
+ message
337
+ end
338
+
339
+ def parse_rate_response(origin, destination, packages, response, options={})
340
+ success = true
341
+ message = ''
342
+ rate_hash = {}
343
+
344
+ xml = REXML::Document.new(response)
345
+
346
+ if error = xml.elements['/Error']
347
+ success = false
348
+ message = error.elements['Description'].text
349
+ else
350
+ xml.elements.each('/*/Package') do |package|
351
+ if package.elements['Error']
352
+ success = false
353
+ message = package.get_text('Error/Description').to_s
354
+ break
355
+ end
356
+ end
357
+
358
+ if success
359
+ rate_hash = rates_from_response_node(xml, packages)
360
+ unless rate_hash
361
+ success = false
362
+ message = "Unknown root node in XML response: '#{root_node_name}'"
363
+ end
364
+ end
365
+
366
+ end
367
+
368
+ rate_estimates = rate_hash.keys.map do |service_name|
369
+ RateEstimate.new(origin,destination,@@name,"USPS #{service_name}",
370
+ :package_rates => rate_hash[service_name][:package_rates],
371
+ :service_code => rate_hash[service_name][:service_code],
372
+ :currency => 'USD')
373
+ end
374
+ rate_estimates.reject! {|e| e.package_count != packages.length}
375
+ rate_estimates = rate_estimates.sort_by(&:total_price)
376
+
377
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
378
+ end
379
+
380
+ def rates_from_response_node(response_node, packages)
381
+ rate_hash = {}
382
+ return false unless (root_node = response_node.elements['/IntlRateResponse | /RateV3Response'])
383
+ domestic = (root_node.name == 'RateV3Response')
384
+
385
+ domestic_elements = ['Postage', 'CLASSID', 'MailService', 'Rate']
386
+ international_elements = ['Service', 'ID', 'SvcDescription', 'Postage']
387
+ service_node, service_code_node, service_name_node, rate_node = domestic ? domestic_elements : international_elements
388
+
389
+ root_node.each_element('Package') do |package_node|
390
+ package_index = package_node.attributes['ID'].to_i
391
+
392
+ package_node.each_element(service_node) do |service_response_node|
393
+ service_name = service_response_node.get_text(service_name_node).to_s
394
+
395
+ # aggregate specific package rates into a service-centric RateEstimate
396
+ # first package with a given service name will initialize these;
397
+ # later packages with same service will add to them
398
+ this_service = rate_hash[service_name] ||= {}
399
+ this_service[:service_code] ||= service_response_node.attributes[service_code_node]
400
+ package_rates = this_service[:package_rates] ||= []
401
+ this_package_rate = {:package => (this_package = packages[package_index]),
402
+ :rate => Package.cents_from(service_response_node.get_text(rate_node).to_s.to_f)}
403
+
404
+ package_rates << this_package_rate if package_valid_for_service(this_package,service_response_node)
405
+ end
406
+ end
407
+ rate_hash
408
+ end
409
+
410
+ def package_valid_for_service(package, service_node)
411
+ return true if service_node.elements['MaxWeight'].nil?
412
+ max_weight = service_node.get_text('MaxWeight').to_s.to_f
413
+ name = service_node.get_text('SvcDescription | MailService').to_s.downcase
414
+
415
+ if name =~ /flat.rate.box/ #domestic or international flat rate box
416
+ # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
417
+ return (package_valid_for_max_dimensions(package,
418
+ :weight => max_weight, #domestic apparently has no weight restriction
419
+ :length => 11.0,
420
+ :width => 8.5,
421
+ :height => 5.5) or
422
+ package_valid_for_max_dimensions(package,
423
+ :weight => max_weight,
424
+ :length => 13.625,
425
+ :width => 11.875,
426
+ :height => 3.375))
427
+ elsif name =~ /flat.rate.envelope/
428
+ return package_valid_for_max_dimensions(package,
429
+ :weight => max_weight,
430
+ :length => 12.5,
431
+ :width => 9.5,
432
+ :height => 0.75)
433
+ elsif service_node.elements['MailService'] # domestic non-flat rates
434
+ return true
435
+ else #international non-flat rates
436
+ # Some sample english that this is required to parse:
437
+ #
438
+ # 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
439
+ # 'Max. length 24", Max. length, height, depth combined 36"'
440
+ #
441
+ sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
442
+ tokens = sentence.downcase.split(/[^\d]*"/).reject {|t| t.empty?}
443
+ max_dimensions = {:weight => max_weight}
444
+ single_axis_values = []
445
+ tokens.each do |token|
446
+ axis_sum = [/length/,/width/,/height/,/depth/].sum {|regex| (token =~ regex) ? 1 : 0}
447
+ unless axis_sum == 0
448
+ value = token[/\d+$/].to_f
449
+ if axis_sum == 3
450
+ max_dimensions[:length_plus_width_plus_height] = value
451
+ elsif token =~ /girth/ and axis_sum == 1
452
+ max_dimensions[:length_plus_girth] = value
453
+ else
454
+ single_axis_values << value
455
+ end
456
+ end
457
+ end
458
+ single_axis_values.sort!.reverse!
459
+ [:length, :width, :height].each_with_index do |axis,i|
460
+ max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
461
+ end
462
+ return package_valid_for_max_dimensions(package, max_dimensions)
463
+ end
464
+ end
465
+
466
+ def package_valid_for_max_dimensions(package,dimensions)
467
+ valid = ((not ([:length,:width,:height].map {|dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f}.include?(false))) and
468
+ (dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
469
+ (dimensions[:length_plus_girth].nil? or
470
+ dimensions[:length_plus_girth].to_f >=
471
+ package.inches(:length) + package.inches(:girth)) and
472
+ (dimensions[:length_plus_width_plus_height].nil? or
473
+ dimensions[:length_plus_width_plus_height].to_f >=
474
+ package.inches(:length) + package.inches(:width) + package.inches(:height)))
475
+
476
+ return valid
477
+ end
478
+
479
+ def commit(action, request, test = false)
480
+ ssl_get(request_url(action, request, test))
481
+ end
482
+
483
+ def request_url(action, request, test)
484
+ scheme = USE_SSL[action] ? 'https://' : 'http://'
485
+ host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
486
+ resource = test ? TEST_RESOURCE : LIVE_RESOURCE
487
+ "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
488
+ end
489
+
490
+ def strip_zip(zip)
491
+ zip.to_s.scan(/\d{5}/).first || zip
492
+ end
493
+
494
+ end
495
+ end
496
+ end