benhutton-active_shipping 0.9.13

Sign up to get free protection for your applications and to get access to all the features.
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,139 @@
1
+ module ActiveMerchant
2
+ module Shipping
3
+ class NewZealandPost < Carrier
4
+
5
+ # class NewZealandPostRateResponse < RateResponse
6
+ # end
7
+
8
+ cattr_reader :name
9
+ @@name = "New Zealand Post"
10
+
11
+ URL = "http://workshop.nzpost.co.nz/api/v1/rate.xml"
12
+
13
+ # Override to return required keys in options hash for initialize method.
14
+ def requirements
15
+ [:key]
16
+ end
17
+
18
+ # Override with whatever you need to get the rates
19
+ def find_rates(origin, destination, packages, options = {})
20
+ packages = Array(packages)
21
+ rate_responses = []
22
+ packages.each do |package|
23
+ if package.tube?
24
+ request_hash = build_tube_request_params(origin, destination, package, options)
25
+ else
26
+ request_hash = build_rectangular_request_params(origin, destination, package, options)
27
+ end
28
+ url = URL + '?' + request_hash.to_param
29
+ response = ssl_get(url)
30
+ rate_responses << parse_rate_response(origin, destination, package, response, options)
31
+ end
32
+ combine_rate_responses(rate_responses, packages)
33
+ end
34
+
35
+ def maximum_weight
36
+ Mass.new(20, :kilograms)
37
+ end
38
+
39
+ protected
40
+
41
+ # Override in subclasses for non-U.S.-based carriers.
42
+ def self.default_location
43
+ Location.new(:postal_code => '6011')
44
+ end
45
+
46
+ private
47
+
48
+ def build_rectangular_request_params(origin, destination, package, options = {})
49
+ params = {
50
+ :postcode_src => origin.postal_code,
51
+ :postcode_dest => destination.postal_code,
52
+ :api_key => @options[:key],
53
+ :height => "#{package.centimetres(:height) * 10}",
54
+ :thickness => "#{package.centimetres(:width) * 10}",
55
+ :length => "#{package.centimetres(:length) * 10}",
56
+ :weight =>"%.1f" % (package.weight.amount / 1000.0)
57
+ }
58
+ end
59
+
60
+ def build_tube_request_params(origin, destination, package, options = {})
61
+ params = {
62
+ :postcode_src => origin.postal_code,
63
+ :postcode_dest => destination.postal_code,
64
+ :api_key => @options[:key],
65
+ :diameter => "#{package.centimetres(:width) * 10}",
66
+ :length => "#{package.centimetres(:length) * 10}",
67
+ :weight => "%.1f" % (package.weight.amount / 1000.0)
68
+ }
69
+ end
70
+
71
+ def parse_rate_response(origin, destination, package, response, options={})
72
+ xml = REXML::Document.new(response)
73
+ if response_success?(xml)
74
+ rate_estimates = []
75
+ xml.elements.each('hash/products/product') do |prod|
76
+ if( prod.get_text('packaging') == 'postage_only' )
77
+ rate_estimates << RateEstimate.new(origin,
78
+ destination,
79
+ @@name,
80
+ prod.get_text('service-group-description').to_s,
81
+ :total_price => prod.get_text('cost').to_s.to_f,
82
+ :currency => 'NZD',
83
+ :service_code => prod.get_text('code').to_s,
84
+ :package => package)
85
+ end
86
+ end
87
+
88
+ RateResponse.new(true, "Success", Hash.from_xml(response), :rates => rate_estimates, :xml => response)
89
+ else
90
+ error_message = response_message(xml)
91
+ RateResponse.new(false, error_message, Hash.from_xml(response), :rates => rate_estimates, :xml => response)
92
+ end
93
+ end
94
+
95
+ def combine_rate_responses(rate_responses, packages)
96
+ #if there are any failed responses, return on that response
97
+ rate_responses.each do |r|
98
+ return r if !r.success?
99
+ end
100
+
101
+ #group rate estimates by delivery type so that we can exclude any incomplete delviery types
102
+ rate_estimate_delivery_types = {}
103
+ rate_responses.each do |rr|
104
+ rr.rate_estimates.each do |re|
105
+ (rate_estimate_delivery_types[re.service_code] ||= []) << re
106
+ end
107
+ end
108
+ rate_estimate_delivery_types.delete_if{ |type, re| re.size != packages.size }
109
+
110
+ #combine cost estimates for remaining packages
111
+ combined_rate_estimates = []
112
+ rate_estimate_delivery_types.each do |type, re|
113
+ total_price = re.sum(&:total_price)
114
+ r = re.first
115
+ combined_rate_estimates << RateEstimate.new(r.origin, r.destination, r.carrier,
116
+ r.service_name,
117
+ :total_price => total_price,
118
+ :currency => r.currency,
119
+ :service_code => r.service_code,
120
+ :packages => packages)
121
+ end
122
+ RateResponse.new(true, "Success", {}, :rates => combined_rate_estimates)
123
+ end
124
+
125
+ def response_success?(xml)
126
+ xml.get_text('hash/status').to_s == 'success'
127
+ end
128
+
129
+ def response_message(xml)
130
+ if response_success?(xml)
131
+ 'Success'
132
+ else
133
+ xml.get_text('hash/message').to_s
134
+ end
135
+ end
136
+
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,172 @@
1
+ require 'cgi'
2
+ require 'builder'
3
+
4
+ module ActiveMerchant
5
+ module Shipping
6
+ class Shipwire < Carrier
7
+ self.retry_safe = true
8
+
9
+ cattr_reader :name
10
+ @@name = "Shipwire"
11
+
12
+ URL = 'https://api.shipwire.com/exec/RateServices.php'
13
+ SCHEMA_URL = 'http://www.shipwire.com/exec/download/RateRequest.dtd'
14
+ WAREHOUSES = { 'CHI' => 'Chicago',
15
+ 'LAX' => 'Los Angeles',
16
+ 'REN' => 'Reno',
17
+ 'VAN' => 'Vancouver',
18
+ 'TOR' => 'Toronto',
19
+ 'UK' => 'United Kingdom'
20
+ }
21
+
22
+ CARRIERS = [ "UPS", "USPS", "FedEx", "Royal Mail", "Parcelforce", "Pharos", "Eurotrux", "Canada Post", "DHL" ]
23
+
24
+ SUCCESS = "OK"
25
+ SUCCESS_MESSAGE = "Successfully received the shipping rates"
26
+ NO_RATES_MESSAGE = "No shipping rates could be found for the destination address"
27
+ REQUIRED_OPTIONS = [:login, :password].freeze
28
+
29
+ def find_rates(origin, destination, packages, options = {})
30
+ requires!(options, :items)
31
+ commit(origin, destination, options)
32
+ end
33
+
34
+ def valid_credentials?
35
+ location = self.class.default_location
36
+ find_rates(location, location, Package.new(100, [5,15,30]),
37
+ :items => [ { :sku => '', :quantity => 1 } ]
38
+ )
39
+ rescue ActiveMerchant::Shipping::ResponseError => e
40
+ e.message != "Could not verify e-mail/password combination"
41
+ end
42
+
43
+ private
44
+ def requirements
45
+ REQUIRED_OPTIONS
46
+ end
47
+
48
+ def build_request(destination, options)
49
+ xml = Builder::XmlMarkup.new
50
+ xml.instruct!
51
+ xml.declare! :DOCTYPE, :RateRequest, :SYSTEM, SCHEMA_URL
52
+ xml.tag! 'RateRequest' do
53
+ add_credentials(xml)
54
+ add_order(xml, destination, options)
55
+ end
56
+ xml.target!
57
+ end
58
+
59
+ def add_credentials(xml)
60
+ xml.tag! 'EmailAddress', @options[:login]
61
+ xml.tag! 'Password', @options[:password]
62
+ end
63
+
64
+ def add_order(xml, destination, options)
65
+ xml.tag! 'Order', :id => options[:order_id] do
66
+ xml.tag! 'Warehouse', options[:warehouse] || '00'
67
+
68
+ add_address(xml, destination)
69
+ Array(options[:items]).each_with_index do |line_item, index|
70
+ add_item(xml, line_item, index)
71
+ end
72
+ end
73
+ end
74
+
75
+ def add_address(xml, destination)
76
+ xml.tag! 'AddressInfo', :type => 'Ship' do
77
+ if destination.name.present?
78
+ xml.tag! 'Name' do
79
+ xml.tag! 'Full', destination.name
80
+ end
81
+ end
82
+ xml.tag! 'Address1', destination.address1
83
+ xml.tag! 'Address2', destination.address2 unless destination.address2.blank?
84
+ xml.tag! 'Address3', destination.address3 unless destination.address3.blank?
85
+ xml.tag! 'City', destination.city
86
+ xml.tag! 'State', destination.state unless destination.state.blank?
87
+ xml.tag! 'Country', destination.country_code
88
+ xml.tag! 'Zip', destination.zip unless destination.zip.blank?
89
+ end
90
+ end
91
+
92
+ # Code is limited to 12 characters
93
+ def add_item(xml, item, index)
94
+ xml.tag! 'Item', :num => index do
95
+ xml.tag! 'Code', item[:sku]
96
+ xml.tag! 'Quantity', item[:quantity]
97
+ end
98
+ end
99
+
100
+ def commit(origin, destination, options)
101
+ request = build_request(destination, options)
102
+ save_request(request)
103
+
104
+ response = parse( ssl_post(URL, "RateRequestXML=#{CGI.escape(request)}") )
105
+
106
+ RateResponse.new(response["success"], response["message"], response,
107
+ :xml => response,
108
+ :rates => build_rate_estimates(response, origin, destination),
109
+ :request => last_request
110
+ )
111
+ end
112
+
113
+ def build_rate_estimates(response, origin, destination)
114
+ response["rates"].collect do |quote|
115
+ RateEstimate.new(origin, destination, carrier_for(quote["service"]), quote["service"],
116
+ :service_code => quote["method"],
117
+ :total_price => quote["cost"],
118
+ :currency => quote["currency"]
119
+ )
120
+ end
121
+ end
122
+
123
+ def carrier_for(service)
124
+ CARRIERS.dup.find{ |carrier| service.to_s =~ /^#{carrier}/i } || service.to_s.split(" ").first
125
+ end
126
+
127
+ def parse(xml)
128
+ response = {}
129
+ response["rates"] = []
130
+
131
+ document = REXML::Document.new(xml)
132
+
133
+ response["status"] = parse_child_text(document.root, "Status")
134
+
135
+ document.root.elements.each("Order/Quotes/Quote") do |e|
136
+ rate = {}
137
+ rate["method"] = e.attributes["method"]
138
+ rate["warehouse"] = parse_child_text(e, "Warehouse")
139
+ rate["service"] = parse_child_text(e, "Service")
140
+ rate["cost"] = parse_child_text(e, "Cost")
141
+ rate["currency"] = parse_child_attribute(e, "Cost", "currency")
142
+ response["rates"] << rate
143
+ end
144
+
145
+ if response["status"] == SUCCESS && response["rates"].any?
146
+ response["success"] = true
147
+ response["message"] = SUCCESS_MESSAGE
148
+ elsif response["status"] == SUCCESS && response["rates"].empty?
149
+ response["success"] = false
150
+ response["message"] = NO_RATES_MESSAGE
151
+ else
152
+ response["success"] = false
153
+ response["message"] = parse_child_text(document.root, "ErrorMessage")
154
+ end
155
+
156
+ response
157
+ end
158
+
159
+ def parse_child_text(parent, name)
160
+ if element = parent.elements[name]
161
+ element.text
162
+ end
163
+ end
164
+
165
+ def parse_child_attribute(parent, name, attribute)
166
+ if element = parent.elements[name]
167
+ element.attributes[attribute]
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,390 @@
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 = HashWithIndifferentAccess.new({
21
+ :daily_pickup => "01",
22
+ :customer_counter => "03",
23
+ :one_time_pickup => "06",
24
+ :on_call_air => "07",
25
+ :suggested_retail_rates => "11",
26
+ :letter_center => "19",
27
+ :air_service_center => "20"
28
+ })
29
+
30
+ CUSTOMER_CLASSIFICATIONS = HashWithIndifferentAccess.new({
31
+ :wholesale => "01",
32
+ :occasional => "03",
33
+ :retail => "04"
34
+ })
35
+
36
+ # these are the defaults described in the UPS API docs,
37
+ # but they don't seem to apply them under all circumstances,
38
+ # so we need to take matters into our own hands
39
+ DEFAULT_CUSTOMER_CLASSIFICATIONS = Hash.new do |hash,key|
40
+ hash[key] = case key.to_sym
41
+ when :daily_pickup then :wholesale
42
+ when :customer_counter then :retail
43
+ else
44
+ :occasional
45
+ end
46
+ end
47
+
48
+ DEFAULT_SERVICES = {
49
+ "01" => "UPS Next Day Air",
50
+ "02" => "UPS Second Day Air",
51
+ "03" => "UPS Ground",
52
+ "07" => "UPS Worldwide Express",
53
+ "08" => "UPS Worldwide Expedited",
54
+ "11" => "UPS Standard",
55
+ "12" => "UPS Three-Day Select",
56
+ "13" => "UPS Next Day Air Saver",
57
+ "14" => "UPS Next Day Air Early A.M.",
58
+ "54" => "UPS Worldwide Express Plus",
59
+ "59" => "UPS Second Day Air A.M.",
60
+ "65" => "UPS Saver",
61
+ "82" => "UPS Today Standard",
62
+ "83" => "UPS Today Dedicated Courier",
63
+ "84" => "UPS Today Intercity",
64
+ "85" => "UPS Today Express",
65
+ "86" => "UPS Today Express Saver"
66
+ }
67
+
68
+ CANADA_ORIGIN_SERVICES = {
69
+ "01" => "UPS Express",
70
+ "02" => "UPS Expedited",
71
+ "14" => "UPS Express Early A.M."
72
+ }
73
+
74
+ MEXICO_ORIGIN_SERVICES = {
75
+ "07" => "UPS Express",
76
+ "08" => "UPS Expedited",
77
+ "54" => "UPS Express Plus"
78
+ }
79
+
80
+ EU_ORIGIN_SERVICES = {
81
+ "07" => "UPS Express",
82
+ "08" => "UPS Expedited"
83
+ }
84
+
85
+ OTHER_NON_US_ORIGIN_SERVICES = {
86
+ "07" => "UPS Express"
87
+ }
88
+
89
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
90
+ 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"]
91
+
92
+ US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
93
+
94
+ def requirements
95
+ [:key, :login, :password]
96
+ end
97
+
98
+ def find_rates(origin, destination, packages, options={})
99
+ origin, destination = upsified_location(origin), upsified_location(destination)
100
+ options = @options.merge(options)
101
+ packages = Array(packages)
102
+ access_request = build_access_request
103
+ rate_request = build_rate_request(origin, destination, packages, options)
104
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
105
+ parse_rate_response(origin, destination, packages, response, options)
106
+ end
107
+
108
+ def find_tracking_info(tracking_number, options={})
109
+ options = @options.update(options)
110
+ access_request = build_access_request
111
+ tracking_request = build_tracking_request(tracking_number, options)
112
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
113
+ parse_tracking_response(response, options)
114
+ end
115
+
116
+ protected
117
+
118
+ def upsified_location(location)
119
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
120
+ atts = {:country => location.state}
121
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
122
+ atts[att] = location.send(att)
123
+ end
124
+ Location.new(atts)
125
+ else
126
+ location
127
+ end
128
+ end
129
+
130
+ def build_access_request
131
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
132
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
133
+ access_request << XmlNode.new('UserId', @options[:login])
134
+ access_request << XmlNode.new('Password', @options[:password])
135
+ end
136
+ xml_request.to_s
137
+ end
138
+
139
+ def build_rate_request(origin, destination, packages, options={})
140
+ packages = Array(packages)
141
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
142
+ root_node << XmlNode.new('Request') do |request|
143
+ request << XmlNode.new('RequestAction', 'Rate')
144
+ request << XmlNode.new('RequestOption', 'Shop')
145
+ # not implemented: 'Rate' RequestOption to specify a single service query
146
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
147
+ end
148
+
149
+ pickup_type = options[:pickup_type] || :daily_pickup
150
+
151
+ root_node << XmlNode.new('PickupType') do |pickup_type_node|
152
+ pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
153
+ # not implemented: PickupType/PickupDetails element
154
+ end
155
+ cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
156
+ root_node << XmlNode.new('CustomerClassification') do |cc_node|
157
+ cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
158
+ end
159
+
160
+ root_node << XmlNode.new('Shipment') do |shipment|
161
+ # not implemented: Shipment/Description element
162
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
163
+ shipment << build_location_node('ShipTo', destination, options)
164
+ if options[:shipper] and options[:shipper] != origin
165
+ shipment << build_location_node('ShipFrom', origin, options)
166
+ end
167
+
168
+ # not implemented: * Shipment/ShipmentWeight element
169
+ # * Shipment/ReferenceNumber element
170
+ # * Shipment/Service element
171
+ # * Shipment/PickupDate element
172
+ # * Shipment/ScheduledDeliveryDate element
173
+ # * Shipment/ScheduledDeliveryTime element
174
+ # * Shipment/AlternateDeliveryTime element
175
+ # * Shipment/DocumentsOnly element
176
+
177
+ packages.each do |package|
178
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
179
+
180
+ shipment << XmlNode.new("Package") do |package_node|
181
+
182
+ # not implemented: * Shipment/Package/PackagingType element
183
+ # * Shipment/Package/Description element
184
+
185
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
186
+ packaging_type << XmlNode.new("Code", '02')
187
+ end
188
+
189
+ package_node << XmlNode.new("Dimensions") do |dimensions|
190
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
191
+ units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
192
+ end
193
+ [:length,:width,:height].each do |axis|
194
+ value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
195
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
196
+ end
197
+ end
198
+
199
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
200
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
201
+ units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
202
+ end
203
+
204
+ value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
205
+ package_weight << XmlNode.new("Weight", [value,0.1].max)
206
+ end
207
+
208
+ # not implemented: * Shipment/Package/LargePackageIndicator element
209
+ # * Shipment/Package/ReferenceNumber element
210
+ # * Shipment/Package/PackageServiceOptions element
211
+ # * Shipment/Package/AdditionalHandling element
212
+ end
213
+
214
+ end
215
+
216
+ # not implemented: * Shipment/ShipmentServiceOptions element
217
+ # * Shipment/RateInformation element
218
+
219
+ end
220
+
221
+ end
222
+ xml_request.to_s
223
+ end
224
+
225
+ def build_tracking_request(tracking_number, options={})
226
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
227
+ root_node << XmlNode.new('Request') do |request|
228
+ request << XmlNode.new('RequestAction', 'Track')
229
+ request << XmlNode.new('RequestOption', '1')
230
+ end
231
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
232
+ end
233
+ xml_request.to_s
234
+ end
235
+
236
+ def build_location_node(name,location,options={})
237
+ # not implemented: * Shipment/Shipper/Name element
238
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
239
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
240
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
241
+ location_node = XmlNode.new(name) do |location_node|
242
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
243
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
244
+
245
+ if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
246
+ location_node << XmlNode.new('ShipperNumber', origin_account)
247
+ elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
248
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
249
+ end
250
+
251
+ location_node << XmlNode.new('Address') do |address|
252
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
253
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
254
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
255
+ address << XmlNode.new("City", location.city) unless location.city.blank?
256
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
257
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
258
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
259
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
260
+ 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
261
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
262
+ end
263
+ end
264
+ end
265
+
266
+ def parse_rate_response(origin, destination, packages, response, options={})
267
+ rates = []
268
+
269
+ xml = REXML::Document.new(response)
270
+ success = response_success?(xml)
271
+ message = response_message(xml)
272
+
273
+ if success
274
+ rate_estimates = []
275
+
276
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
277
+ service_code = rated_shipment.get_text('Service/Code').to_s
278
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
279
+ service_name_for(origin, service_code),
280
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
281
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
282
+ :service_code => service_code,
283
+ :packages => packages)
284
+ end
285
+ end
286
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
287
+ end
288
+
289
+ def parse_tracking_response(response, options={})
290
+ xml = REXML::Document.new(response)
291
+ success = response_success?(xml)
292
+ message = response_message(xml)
293
+
294
+ if success
295
+ tracking_number, origin, destination = nil
296
+ shipment_events = []
297
+
298
+ first_shipment = xml.elements['/*/Shipment']
299
+ first_package = first_shipment.elements['Package']
300
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
301
+
302
+ origin, destination = %w{Shipper ShipTo}.map do |location|
303
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
304
+ end
305
+
306
+ activities = first_package.get_elements('Activity')
307
+ unless activities.empty?
308
+ shipment_events = activities.map do |activity|
309
+ description = activity.get_text('Status/StatusType/Description').to_s
310
+ zoneless_time = if (time = activity.get_text('Time')) &&
311
+ (date = activity.get_text('Date'))
312
+ time, date = time.to_s, date.to_s
313
+ hour, minute, second = time.scan(/\d{2}/)
314
+ year, month, day = date[0..3], date[4..5], date[6..7]
315
+ Time.utc(year, month, day, hour, minute, second)
316
+ end
317
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
318
+ ShipmentEvent.new(description, zoneless_time, location)
319
+ end
320
+
321
+ shipment_events = shipment_events.sort_by(&:time)
322
+
323
+ if origin
324
+ first_event = shipment_events[0]
325
+ same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
326
+ same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
327
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
328
+ if same_country and same_or_blank_city
329
+ shipment_events[0] = origin_event
330
+ else
331
+ shipment_events.unshift(origin_event)
332
+ end
333
+ end
334
+ if shipment_events.last.name.downcase == 'delivered'
335
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
336
+ end
337
+ end
338
+
339
+ end
340
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
341
+ :xml => response,
342
+ :request => last_request,
343
+ :shipment_events => shipment_events,
344
+ :origin => origin,
345
+ :destination => destination,
346
+ :tracking_number => tracking_number)
347
+ end
348
+
349
+ def location_from_address_node(address)
350
+ return nil unless address
351
+ Location.new(
352
+ :country => node_text_or_nil(address.elements['CountryCode']),
353
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
354
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
355
+ :city => node_text_or_nil(address.elements['City']),
356
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
357
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
358
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
359
+ )
360
+ end
361
+
362
+ def response_success?(xml)
363
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
364
+ end
365
+
366
+ def response_message(xml)
367
+ xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
368
+ end
369
+
370
+ def commit(action, request, test = false)
371
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
372
+ end
373
+
374
+
375
+ def service_name_for(origin, code)
376
+ origin = origin.country_code(:alpha2)
377
+
378
+ name = case origin
379
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
380
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
381
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
382
+ end
383
+
384
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
385
+ name ||= DEFAULT_SERVICES[code]
386
+ end
387
+
388
+ end
389
+ end
390
+ end