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,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