benhutton-active_shipping 0.9.13

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 (54) hide show
  1. data/CHANGELOG +38 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.markdown +142 -0
  4. data/lib/active_merchant/common.rb +14 -0
  5. data/lib/active_merchant/common/connection.rb +177 -0
  6. data/lib/active_merchant/common/country.rb +328 -0
  7. data/lib/active_merchant/common/error.rb +26 -0
  8. data/lib/active_merchant/common/post_data.rb +24 -0
  9. data/lib/active_merchant/common/posts_data.rb +63 -0
  10. data/lib/active_merchant/common/requires_parameters.rb +16 -0
  11. data/lib/active_merchant/common/utils.rb +22 -0
  12. data/lib/active_merchant/common/validateable.rb +76 -0
  13. data/lib/active_shipping.rb +49 -0
  14. data/lib/active_shipping/shipping/base.rb +13 -0
  15. data/lib/active_shipping/shipping/carrier.rb +70 -0
  16. data/lib/active_shipping/shipping/carriers.rb +20 -0
  17. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +16 -0
  18. data/lib/active_shipping/shipping/carriers/canada_post.rb +268 -0
  19. data/lib/active_shipping/shipping/carriers/fedex.rb +331 -0
  20. data/lib/active_shipping/shipping/carriers/kunaki.rb +165 -0
  21. data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +139 -0
  22. data/lib/active_shipping/shipping/carriers/shipwire.rb +172 -0
  23. data/lib/active_shipping/shipping/carriers/ups.rb +390 -0
  24. data/lib/active_shipping/shipping/carriers/usps.rb +441 -0
  25. data/lib/active_shipping/shipping/location.rb +109 -0
  26. data/lib/active_shipping/shipping/package.rb +147 -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 +46 -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/active_shipping/version.rb +3 -0
  33. data/lib/certs/cacert.pem +7815 -0
  34. data/lib/certs/eParcel.dtd +111 -0
  35. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  36. data/lib/vendor/quantified/README.markdown +49 -0
  37. data/lib/vendor/quantified/Rakefile +21 -0
  38. data/lib/vendor/quantified/init.rb +0 -0
  39. data/lib/vendor/quantified/lib/quantified.rb +8 -0
  40. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  41. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  42. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  43. data/lib/vendor/quantified/test/length_test.rb +92 -0
  44. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  45. data/lib/vendor/quantified/test/test_helper.rb +2 -0
  46. data/lib/vendor/test_helper.rb +13 -0
  47. data/lib/vendor/xml_node/README +36 -0
  48. data/lib/vendor/xml_node/Rakefile +21 -0
  49. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  50. data/lib/vendor/xml_node/init.rb +1 -0
  51. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  52. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  53. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  54. metadata +125 -0
@@ -0,0 +1,441 @@
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 => 'RateV4',
33
+ :world_rates => 'IntlRateV2',
34
+ :test => 'CarrierPickupAvailability'
35
+ }
36
+ USE_SSL = {
37
+ :us_rates => false,
38
+ :world_rates => false,
39
+ :test => true
40
+ }
41
+ CONTAINERS = {
42
+ :envelope => 'Flat Rate Envelope',
43
+ :box => 'Flat Rate Box'
44
+ }
45
+ MAIL_TYPES = {
46
+ :package => 'Package',
47
+ :postcard => 'Postcards or aerogrammes',
48
+ :matter_for_the_blind => 'Matter for the blind',
49
+ :envelope => 'Envelope'
50
+ }
51
+ PACKAGE_PROPERTIES = {
52
+ 'ZipOrigination' => :origin_zip,
53
+ 'ZipDestination' => :destination_zip,
54
+ 'Pounds' => :pounds,
55
+ 'Ounces' => :ounces,
56
+ 'Container' => :container,
57
+ 'Size' => :size,
58
+ 'Machinable' => :machinable,
59
+ 'Zone' => :zone,
60
+ 'Postage' => :postage,
61
+ 'Restrictions' => :restrictions
62
+ }
63
+ POSTAGE_PROPERTIES = {
64
+ 'MailService' => :service,
65
+ 'Rate' => :rate
66
+ }
67
+ US_SERVICES = {
68
+ :first_class => 'FIRST CLASS',
69
+ :priority => 'PRIORITY',
70
+ :express => 'EXPRESS',
71
+ :bpm => 'BPM',
72
+ :parcel => 'PARCEL',
73
+ :media => 'MEDIA',
74
+ :library => 'LIBRARY',
75
+ :all => 'ALL'
76
+ }
77
+
78
+ # 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
79
+ # TODO: figure out how USPS likes to say "Ivory Coast"
80
+ #
81
+ # Country names:
82
+ # http://pe.usps.gov/text/Imm/immctry.htm
83
+ COUNTRY_NAME_CONVERSIONS = {
84
+ "BA" => "Bosnia-Herzegovina",
85
+ "CD" => "Congo, Democratic Republic of the",
86
+ "CG" => "Congo (Brazzaville),Republic of the",
87
+ "CI" => "Côte d'Ivoire (Ivory Coast)",
88
+ "CK" => "Cook Islands (New Zealand)",
89
+ "FK" => "Falkland Islands",
90
+ "GB" => "Great Britain and Northern Ireland",
91
+ "GE" => "Georgia, Republic of",
92
+ "IR" => "Iran",
93
+ "KN" => "Saint Kitts (St. Christopher and Nevis)",
94
+ "KP" => "North Korea (Korea, Democratic People's Republic of)",
95
+ "KR" => "South Korea (Korea, Republic of)",
96
+ "LA" => "Laos",
97
+ "LY" => "Libya",
98
+ "MC" => "Monaco (France)",
99
+ "MD" => "Moldova",
100
+ "MK" => "Macedonia, Republic of",
101
+ "MM" => "Burma",
102
+ "PN" => "Pitcairn Island",
103
+ "RU" => "Russia",
104
+ "SK" => "Slovak Republic",
105
+ "TK" => "Tokelau (Union) Group (Western Samoa)",
106
+ "TW" => "Taiwan",
107
+ "TZ" => "Tanzania",
108
+ "VA" => "Vatican City",
109
+ "VG" => "British Virgin Islands",
110
+ "VN" => "Vietnam",
111
+ "WF" => "Wallis and Futuna Islands",
112
+ "WS" => "Western Samoa"
113
+ }
114
+
115
+ def self.size_code_for(package)
116
+ if package.inches(:max) <= 12
117
+ 'REGULAR'
118
+ else
119
+ 'LARGE'
120
+ end
121
+ end
122
+
123
+ # from info at http://www.usps.com/businessmail101/mailcharacteristics/parcels.htm
124
+ #
125
+ # package.options[:books] -- 25 lb. limit instead of 35 for books or other printed matter.
126
+ # Defaults to false.
127
+ def self.package_machinable?(package, options={})
128
+ at_least_minimum = package.inches(:length) >= 6.0 &&
129
+ package.inches(:width) >= 3.0 &&
130
+ package.inches(:height) >= 0.25 &&
131
+ package.ounces >= 6.0
132
+ at_most_maximum = package.inches(:length) <= 34.0 &&
133
+ package.inches(:width) <= 17.0 &&
134
+ package.inches(:height) <= 17.0 &&
135
+ package.pounds <= (package.options[:books] ? 25.0 : 35.0)
136
+ at_least_minimum && at_most_maximum
137
+ end
138
+
139
+ def requirements
140
+ [:login]
141
+ end
142
+
143
+ def find_rates(origin, destination, packages, options = {})
144
+ options = @options.merge(options)
145
+
146
+ origin = Location.from(origin)
147
+ destination = Location.from(destination)
148
+ packages = Array(packages)
149
+
150
+ #raise ArgumentError.new("USPS packages must originate in the U.S.") unless ['US',nil].include?(origin.country_code(:alpha2))
151
+
152
+
153
+ # domestic or international?
154
+
155
+ response = if ['US',nil].include?(destination.country_code(:alpha2))
156
+ us_rates(origin, destination, packages, options)
157
+ else
158
+ world_rates(origin, destination, packages, options)
159
+ end
160
+ end
161
+
162
+ def valid_credentials?
163
+ # Cannot test with find_rates because USPS doesn't allow that in test mode
164
+ test_mode? ? canned_address_verification_works? : super
165
+ end
166
+
167
+ def maximum_weight
168
+ Mass.new(70, :pounds)
169
+ end
170
+
171
+ protected
172
+
173
+ def us_rates(origin, destination, packages, options={})
174
+ request = build_us_rate_request(packages, origin.zip, destination.zip, options)
175
+ # never use test mode; rate requests just won't work on test servers
176
+ parse_rate_response origin, destination, packages, commit(:us_rates,request,false), options
177
+ end
178
+
179
+ def world_rates(origin, destination, packages, options={})
180
+ request = build_world_rate_request(packages, destination)
181
+ # never use test mode; rate requests just won't work on test servers
182
+ parse_rate_response origin, destination, packages, commit(:world_rates,request,false), options
183
+ end
184
+
185
+ # Once the address verification API is implemented, remove this and have valid_credentials? build the request using that instead.
186
+ def canned_address_verification_works?
187
+ 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"
188
+ # 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"}}
189
+ xml = REXML::Document.new(commit(:test, request, true))
190
+ xml.get_text('/CarrierPickupAvailabilityResponse/City').to_s == 'HOUSTON' &&
191
+ xml.get_text('/CarrierPickupAvailabilityResponse/Address2').to_s == '1390 Market Street'
192
+ end
193
+
194
+ # options[:service] -- One of [:first_class, :priority, :express, :bpm, :parcel,
195
+ # :media, :library, :all]. defaults to :all.
196
+ # options[:container] -- One of [:envelope, :box]. defaults to neither (this field has
197
+ # special meaning in the USPS API).
198
+ # options[:books] -- Either true or false. Packages of books or other printed matter
199
+ # have a lower weight limit to be considered machinable.
200
+ # package.options[:machinable] -- Either true or false. Overrides the detection of
201
+ # "machinability" entirely.
202
+ def build_us_rate_request(packages, origin_zip, destination_zip, options={})
203
+ packages = Array(packages)
204
+ request = XmlNode.new('RateV4Request', :USERID => @options[:login]) do |rate_request|
205
+ packages.each_with_index do |p,id|
206
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
207
+ package << XmlNode.new('Service', US_SERVICES[options[:service] || :all])
208
+ package << XmlNode.new('ZipOrigination', strip_zip(origin_zip))
209
+ package << XmlNode.new('ZipDestination', strip_zip(destination_zip))
210
+ package << XmlNode.new('Pounds', 0)
211
+ package << XmlNode.new('Ounces', "%0.1f" % [p.ounces,1].max)
212
+ package << XmlNode.new('Container', CONTAINERS[p.options[:container]])
213
+ package << XmlNode.new('Size', USPS.size_code_for(p))
214
+ package << XmlNode.new('Width', "%0.2f" % p.inches(:width))
215
+ package << XmlNode.new('Length', "%0.2f" % p.inches(:length))
216
+ package << XmlNode.new('Height', "%0.2f" % p.inches(:height))
217
+ package << XmlNode.new('Girth', "%0.2f" % p.inches(:girth))
218
+ is_machinable = if p.options.has_key?(:machinable)
219
+ p.options[:machinable] ? true : false
220
+ else
221
+ USPS.package_machinable?(p)
222
+ end
223
+ package << XmlNode.new('Machinable', is_machinable.to_s.upcase)
224
+ end
225
+ end
226
+ end
227
+ URI.encode(save_request(request.to_s))
228
+ end
229
+
230
+ # important difference with international rate requests:
231
+ # * services are not given in the request
232
+ # * package sizes are not given in the request
233
+ # * services are returned in the response along with restrictions of size
234
+ # * the size restrictions are returned AS AN ENGLISH SENTENCE (!?)
235
+ #
236
+ #
237
+ # package.options[:mail_type] -- one of [:package, :postcard, :matter_for_the_blind, :envelope].
238
+ # Defaults to :package.
239
+ def build_world_rate_request(packages, destination)
240
+ country = COUNTRY_NAME_CONVERSIONS[destination.country.code(:alpha2).value] || destination.country.name
241
+ request = XmlNode.new('IntlRateV2Request', :USERID => @options[:login]) do |rate_request|
242
+ packages.each_index do |id|
243
+ p = packages[id]
244
+ rate_request << XmlNode.new('Package', :ID => id.to_s) do |package|
245
+ package << XmlNode.new('Pounds', 0)
246
+ package << XmlNode.new('Ounces', [p.ounces,1].max.ceil) #takes an integer for some reason, must be rounded UP
247
+ package << XmlNode.new('MailType', MAIL_TYPES[p.options[:mail_type]] || 'Package')
248
+ package << XmlNode.new('GXG') do |gxg|
249
+ gxg << XmlNode.new('POBoxFlag', destination.po_box? ? 'Y' : 'N')
250
+ gxg << XmlNode.new('GiftFlag', p.gift? ? 'Y' : 'N')
251
+ end
252
+ value = if p.value && p.value > 0 && p.currency && p.currency != 'USD'
253
+ 0.0
254
+ else
255
+ (p.value || 0) / 100.0
256
+ end
257
+ package << XmlNode.new('ValueOfContents', value)
258
+ package << XmlNode.new('Country') do |node|
259
+ node.cdata = country
260
+ end
261
+ package << XmlNode.new('Container', p.cylinder? ? 'NONRECTANGULAR' : 'RECTANGULAR')
262
+ package << XmlNode.new('Size', USPS.size_code_for(p))
263
+ package << XmlNode.new('Width', "%0.2f" % [p.inches(:width), 0.01].max)
264
+ package << XmlNode.new('Length', "%0.2f" % [p.inches(:length), 0.01].max)
265
+ package << XmlNode.new('Height', "%0.2f" % [p.inches(:height), 0.01].max)
266
+ package << XmlNode.new('Girth', "%0.2f" % [p.inches(:girth), 0.01].max)
267
+ end
268
+ end
269
+ end
270
+ URI.encode(save_request(request.to_s))
271
+ end
272
+
273
+ def parse_rate_response(origin, destination, packages, response, options={})
274
+ success = true
275
+ message = ''
276
+ rate_hash = {}
277
+
278
+ xml = REXML::Document.new(response)
279
+
280
+ if error = xml.elements['/Error']
281
+ success = false
282
+ message = error.elements['Description'].text
283
+ else
284
+ xml.elements.each('/*/Package') do |package|
285
+ if package.elements['Error']
286
+ success = false
287
+ message = package.get_text('Error/Description').to_s
288
+ break
289
+ end
290
+ end
291
+
292
+ if success
293
+ rate_hash = rates_from_response_node(xml, packages)
294
+ unless rate_hash
295
+ success = false
296
+ message = "Unknown root node in XML response: '#{xml.root.name}'"
297
+ end
298
+ end
299
+
300
+ end
301
+
302
+ if success
303
+ rate_estimates = rate_hash.keys.map do |service_name|
304
+ RateEstimate.new(origin,destination,@@name,"USPS #{service_name}",
305
+ :package_rates => rate_hash[service_name][:package_rates],
306
+ :service_code => rate_hash[service_name][:service_code],
307
+ :currency => 'USD')
308
+ end
309
+ rate_estimates.reject! {|e| e.package_count != packages.length}
310
+ rate_estimates = rate_estimates.sort_by(&:total_price)
311
+ end
312
+
313
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request)
314
+ end
315
+
316
+ def rates_from_response_node(response_node, packages)
317
+ rate_hash = {}
318
+ return false unless (root_node = response_node.elements['/IntlRateV2Response | /RateV4Response'])
319
+ domestic = (root_node.name == 'RateV4Response')
320
+
321
+ domestic_elements = ['Postage', 'CLASSID', 'MailService', 'Rate']
322
+ international_elements = ['Service', 'ID', 'SvcDescription', 'Postage']
323
+ service_node, service_code_node, service_name_node, rate_node = domestic ? domestic_elements : international_elements
324
+
325
+ root_node.each_element('Package') do |package_node|
326
+ this_package = packages[package_node.attributes['ID'].to_i]
327
+
328
+ package_node.each_element(service_node) do |service_response_node|
329
+ service_name = service_response_node.get_text(service_name_node).to_s
330
+
331
+ # strips the double-escaped HTML for trademark symbols from service names
332
+ service_name.gsub!(/&amp;lt;\S*&amp;gt;/,'')
333
+ # ...leading "USPS"
334
+ service_name.gsub!(/^USPS/,'')
335
+ # ...trailing asterisks
336
+ service_name.gsub!(/\*+$/,'')
337
+ # ...surrounding spaces
338
+ service_name.strip!
339
+
340
+ # aggregate specific package rates into a service-centric RateEstimate
341
+ # first package with a given service name will initialize these;
342
+ # later packages with same service will add to them
343
+ this_service = rate_hash[service_name] ||= {}
344
+ this_service[:service_code] ||= service_response_node.attributes[service_code_node]
345
+ package_rates = this_service[:package_rates] ||= []
346
+ this_package_rate = {:package => this_package,
347
+ :rate => Package.cents_from(service_response_node.get_text(rate_node).to_s.to_f)}
348
+
349
+ package_rates << this_package_rate if package_valid_for_service(this_package,service_response_node)
350
+ end
351
+ end
352
+ rate_hash
353
+ end
354
+
355
+ def package_valid_for_service(package, service_node)
356
+ return true if service_node.elements['MaxWeight'].nil?
357
+ max_weight = service_node.get_text('MaxWeight').to_s.to_f
358
+ name = service_node.get_text('SvcDescription | MailService').to_s.downcase
359
+
360
+ if name =~ /flat.rate.box/ #domestic or international flat rate box
361
+ # flat rate dimensions from http://www.usps.com/shipping/flatrate.htm
362
+ return (package_valid_for_max_dimensions(package,
363
+ :weight => max_weight, #domestic apparently has no weight restriction
364
+ :length => 11.0,
365
+ :width => 8.5,
366
+ :height => 5.5) or
367
+ package_valid_for_max_dimensions(package,
368
+ :weight => max_weight,
369
+ :length => 13.625,
370
+ :width => 11.875,
371
+ :height => 3.375))
372
+ elsif name =~ /flat.rate.envelope/
373
+ return package_valid_for_max_dimensions(package,
374
+ :weight => max_weight,
375
+ :length => 12.5,
376
+ :width => 9.5,
377
+ :height => 0.75)
378
+ elsif service_node.elements['MailService'] # domestic non-flat rates
379
+ return true
380
+ else #international non-flat rates
381
+ # Some sample english that this is required to parse:
382
+ #
383
+ # 'Max. length 46", width 35", height 46" and max. length plus girth 108"'
384
+ # 'Max. length 24", Max. length, height, depth combined 36"'
385
+ #
386
+ sentence = CGI.unescapeHTML(service_node.get_text('MaxDimensions').to_s)
387
+ tokens = sentence.downcase.split(/[^\d]*"/).reject {|t| t.empty?}
388
+ max_dimensions = {:weight => max_weight}
389
+ single_axis_values = []
390
+ tokens.each do |token|
391
+ axis_sum = [/length/,/width/,/height/,/depth/].sum {|regex| (token =~ regex) ? 1 : 0}
392
+ unless axis_sum == 0
393
+ value = token[/\d+$/].to_f
394
+ if axis_sum == 3
395
+ max_dimensions[:length_plus_width_plus_height] = value
396
+ elsif token =~ /girth/ and axis_sum == 1
397
+ max_dimensions[:length_plus_girth] = value
398
+ else
399
+ single_axis_values << value
400
+ end
401
+ end
402
+ end
403
+ single_axis_values.sort!.reverse!
404
+ [:length, :width, :height].each_with_index do |axis,i|
405
+ max_dimensions[axis] = single_axis_values[i] if single_axis_values[i]
406
+ end
407
+ return package_valid_for_max_dimensions(package, max_dimensions)
408
+ end
409
+ end
410
+
411
+ def package_valid_for_max_dimensions(package,dimensions)
412
+ valid = ((not ([:length,:width,:height].map {|dim| dimensions[dim].nil? || dimensions[dim].to_f >= package.inches(dim).to_f}.include?(false))) and
413
+ (dimensions[:weight].nil? || dimensions[:weight] >= package.pounds) and
414
+ (dimensions[:length_plus_girth].nil? or
415
+ dimensions[:length_plus_girth].to_f >=
416
+ package.inches(:length) + package.inches(:girth)) and
417
+ (dimensions[:length_plus_width_plus_height].nil? or
418
+ dimensions[:length_plus_width_plus_height].to_f >=
419
+ package.inches(:length) + package.inches(:width) + package.inches(:height)))
420
+
421
+ return valid
422
+ end
423
+
424
+ def commit(action, request, test = false)
425
+ ssl_get(request_url(action, request, test))
426
+ end
427
+
428
+ def request_url(action, request, test)
429
+ scheme = USE_SSL[action] ? 'https://' : 'http://'
430
+ host = test ? TEST_DOMAINS[USE_SSL[action]] : LIVE_DOMAIN
431
+ resource = test ? TEST_RESOURCE : LIVE_RESOURCE
432
+ "#{scheme}#{host}/#{resource}?API=#{API_CODES[action]}&XML=#{request}"
433
+ end
434
+
435
+ def strip_zip(zip)
436
+ zip.to_s.scan(/\d{5}/).first || zip
437
+ end
438
+
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,109 @@
1
+ module ActiveMerchant #:nodoc:
2
+ module Shipping #:nodoc:
3
+ class Location
4
+ ADDRESS_TYPES = %w{residential commercial po_box}
5
+
6
+ attr_reader :options,
7
+ :country,
8
+ :postal_code,
9
+ :province,
10
+ :city,
11
+ :name,
12
+ :address1,
13
+ :address2,
14
+ :address3,
15
+ :phone,
16
+ :fax,
17
+ :address_type
18
+
19
+ alias_method :zip, :postal_code
20
+ alias_method :postal, :postal_code
21
+ alias_method :state, :province
22
+ alias_method :territory, :province
23
+ alias_method :region, :province
24
+
25
+ def initialize(options = {})
26
+ @country = (options[:country].nil? or options[:country].is_a?(ActiveMerchant::Country)) ?
27
+ options[:country] :
28
+ ActiveMerchant::Country.find(options[:country])
29
+ @postal_code = options[:postal_code] || options[:postal] || options[:zip]
30
+ @province = options[:province] || options[:state] || options[:territory] || options[:region]
31
+ @city = options[:city]
32
+ @name = options[:name]
33
+ @address1 = options[:address1]
34
+ @address2 = options[:address2]
35
+ @address3 = options[:address3]
36
+ @phone = options[:phone]
37
+ @fax = options[:fax]
38
+ if options[:address_type].present?
39
+ @address_type = options[:address_type].to_s
40
+ unless ADDRESS_TYPES.include?(@address_type)
41
+ raise ArgumentError.new("address_type must be one of #{ADDRESS_TYPES.map(&:inspect).join(', ')}")
42
+ end
43
+ end
44
+ end
45
+
46
+ def self.from(object, options={})
47
+ return object if object.is_a? ActiveMerchant::Shipping::Location
48
+ attr_mappings = {
49
+ :name => [:name],
50
+ :country => [:country_code, :country],
51
+ :postal_code => [:postal_code, :zip, :postal],
52
+ :province => [:province_code, :state_code, :territory_code, :region_code, :province, :state, :territory, :region],
53
+ :city => [:city, :town],
54
+ :address1 => [:address1, :address, :street],
55
+ :address2 => [:address2],
56
+ :address3 => [:address3],
57
+ :phone => [:phone, :phone_number],
58
+ :fax => [:fax, :fax_number],
59
+ :address_type => [:address_type]
60
+ }
61
+ attributes = {}
62
+ hash_access = begin
63
+ object[:some_symbol]
64
+ true
65
+ rescue
66
+ false
67
+ end
68
+ attr_mappings.each do |pair|
69
+ pair[1].each do |sym|
70
+ if value = (object[sym] if hash_access) || (object.send(sym) if object.respond_to?(sym) && (!hash_access || !Hash.public_instance_methods.include?(sym.to_s)))
71
+ attributes[pair[0]] = value
72
+ break
73
+ end
74
+ end
75
+ end
76
+ attributes.delete(:address_type) unless ADDRESS_TYPES.include?(attributes[:address_type].to_s)
77
+ self.new(attributes.update(options))
78
+ end
79
+
80
+ def country_code(format = :alpha2)
81
+ @country.nil? ? nil : @country.code(format).value
82
+ end
83
+
84
+ def residential?; @address_type == 'residential' end
85
+ def commercial?; @address_type == 'commercial' end
86
+ def po_box?; @address_type == 'po_box' end
87
+
88
+ def to_s
89
+ prettyprint.gsub(/\n/, ' ')
90
+ end
91
+
92
+ def prettyprint
93
+ chunks = []
94
+ chunks << [@name, @address1,@address2,@address3].reject {|e| e.blank?}.join("\n")
95
+ chunks << [@city,@province,@postal_code].reject {|e| e.blank?}.join(', ')
96
+ chunks << @country
97
+ chunks.reject {|e| e.blank?}.join("\n")
98
+ end
99
+
100
+ def inspect
101
+ string = prettyprint
102
+ string << "\nPhone: #{@phone}" unless @phone.blank?
103
+ string << "\nFax: #{@fax}" unless @fax.blank?
104
+ string
105
+ end
106
+ end
107
+
108
+ end
109
+ end