kschadeck-active_shipping 0.9.15

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. data/CHANGELOG +38 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.markdown +126 -0
  4. data/lib/active_shipping.rb +50 -0
  5. data/lib/active_shipping/shipping/base.rb +13 -0
  6. data/lib/active_shipping/shipping/carrier.rb +81 -0
  7. data/lib/active_shipping/shipping/carriers.rb +20 -0
  8. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +16 -0
  9. data/lib/active_shipping/shipping/carriers/canada_post.rb +261 -0
  10. data/lib/active_shipping/shipping/carriers/fedex.rb +372 -0
  11. data/lib/active_shipping/shipping/carriers/kunaki.rb +165 -0
  12. data/lib/active_shipping/shipping/carriers/new_zealand_post.rb +269 -0
  13. data/lib/active_shipping/shipping/carriers/shipwire.rb +178 -0
  14. data/lib/active_shipping/shipping/carriers/ups.rb +452 -0
  15. data/lib/active_shipping/shipping/carriers/usps.rb +441 -0
  16. data/lib/active_shipping/shipping/location.rb +149 -0
  17. data/lib/active_shipping/shipping/package.rb +147 -0
  18. data/lib/active_shipping/shipping/rate_estimate.rb +62 -0
  19. data/lib/active_shipping/shipping/rate_response.rb +19 -0
  20. data/lib/active_shipping/shipping/response.rb +46 -0
  21. data/lib/active_shipping/shipping/shipment_event.rb +14 -0
  22. data/lib/active_shipping/shipping/shipment_packer.rb +48 -0
  23. data/lib/active_shipping/shipping/tracking_response.rb +47 -0
  24. data/lib/active_shipping/version.rb +3 -0
  25. data/lib/certs/eParcel.dtd +111 -0
  26. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  27. data/lib/vendor/quantified/README.markdown +49 -0
  28. data/lib/vendor/quantified/Rakefile +21 -0
  29. data/lib/vendor/quantified/init.rb +0 -0
  30. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  31. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  32. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  33. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  34. data/lib/vendor/quantified/test/length_test.rb +92 -0
  35. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  36. data/lib/vendor/quantified/test/test_helper.rb +10 -0
  37. data/lib/vendor/test_helper.rb +7 -0
  38. data/lib/vendor/xml_node/README +36 -0
  39. data/lib/vendor/xml_node/Rakefile +21 -0
  40. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  41. data/lib/vendor/xml_node/init.rb +1 -0
  42. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  43. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  44. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  45. metadata +233 -0
@@ -0,0 +1,269 @@
1
+ module ActiveMerchant
2
+ module Shipping
3
+ class NewZealandPost < Carrier
4
+
5
+ cattr_reader :name
6
+ @@name = "New Zealand Post"
7
+
8
+ URL = "http://api.nzpost.co.nz/ratefinder"
9
+
10
+ def requirements
11
+ [:key]
12
+ end
13
+
14
+ def find_rates(origin, destination, packages, options = {})
15
+ options = @options.merge(options)
16
+ request = RateRequest.from(origin, destination, packages, options)
17
+ request.raw_responses = commit(request.urls) if request.new_zealand_origin?
18
+ request.rate_response
19
+ end
20
+
21
+ protected
22
+
23
+ def commit(urls)
24
+ save_request(urls).map { |url| ssl_get(url) }
25
+ end
26
+
27
+ def self.default_location
28
+ Location.new({
29
+ :country => "NZ",
30
+ :city => "Wellington",
31
+ :address1 => "22 Waterloo Quay",
32
+ :address2 => "Pipitea",
33
+ :postal_code => "6011"
34
+ })
35
+ end
36
+
37
+ class NewZealandPostRateResponse < RateResponse
38
+
39
+ attr_reader :raw_responses
40
+
41
+ def initialize(success, message, params = {}, options = {})
42
+ @raw_responses = options[:raw_responses]
43
+ super
44
+ end
45
+ end
46
+
47
+ class RateRequest
48
+
49
+ attr_reader :urls
50
+ attr_writer :raw_responses
51
+
52
+ def self.from(*args)
53
+ return International.new(*args) unless domestic?(args[0..1])
54
+ Domestic.new(*args)
55
+ end
56
+
57
+ def initialize(origin, destination, packages, options)
58
+ @origin = Location.from(origin)
59
+ @destination = Location.from(destination)
60
+ @packages = Array(packages).map { |package| NewZealandPostPackage.new(package, api) }
61
+ @params = { :format => "json", :api_key => options[:key] }
62
+ @test = options[:test]
63
+ @rates = @responses = @raw_responses = []
64
+ @urls = @packages.map { |package| url(package) }
65
+ end
66
+
67
+ def rate_response
68
+ @rates = rates
69
+ NewZealandPostRateResponse.new(true, "success", response_params, response_options)
70
+ rescue => error
71
+ NewZealandPostRateResponse.new(false, error.message, response_params, response_options)
72
+ end
73
+
74
+ def new_zealand_origin?
75
+ self.class.new_zealand?(@origin)
76
+ end
77
+
78
+ protected
79
+
80
+ def self.new_zealand?(location)
81
+ [ 'NZ', nil ].include?(Location.from(location).country_code)
82
+ end
83
+
84
+ def self.domestic?(locations)
85
+ locations.select { |location| new_zealand?(location) }.size == 2
86
+ end
87
+
88
+ def response_options
89
+ {
90
+ :rates => @rates,
91
+ :raw_responses => @raw_responses,
92
+ :request => @urls,
93
+ :test => @test
94
+ }
95
+ end
96
+
97
+ def response_params
98
+ { :responses => @responses }
99
+ end
100
+
101
+ def rate_options(products)
102
+ {
103
+ :total_price => products.sum { |product| price(product) },
104
+ :currency => "NZD",
105
+ :service_code => products.first["code"]
106
+ }
107
+ end
108
+
109
+ def rates
110
+ rates_hash.map do |service, products|
111
+ RateEstimate.new(@origin, @destination, NewZealandPost.name, service, rate_options(products))
112
+ end
113
+ end
114
+
115
+ def rates_hash
116
+ products_hash.select { |service, products| products.size == @packages.size }
117
+ end
118
+
119
+ def products_hash
120
+ product_arrays.flatten.group_by { |product| service_name(product) }
121
+ end
122
+
123
+ def product_arrays
124
+ responses.map do |response|
125
+ raise(response["message"]) unless response["status"] == "success"
126
+ response["products"]
127
+ end
128
+ end
129
+
130
+ def responses
131
+ @responses = @raw_responses.map { |response| parse_response(response) }
132
+ end
133
+
134
+ def parse_response(response)
135
+ JSON.parse(response)
136
+ end
137
+
138
+ def url(package)
139
+ "#{URL}/#{api}?#{params(package).to_query}"
140
+ end
141
+
142
+ def params(package)
143
+ @params.merge(api_params).merge(package.params)
144
+ end
145
+
146
+ end
147
+
148
+ class Domestic < RateRequest
149
+ def service_name(product)
150
+ [ product["service_group_description"], product["description"] ].join(" ")
151
+ end
152
+
153
+ def api
154
+ :domestic
155
+ end
156
+
157
+ def api_params
158
+ {
159
+ :postcode_src => @origin.postal_code,
160
+ :postcode_dest => @destination.postal_code,
161
+ :carrier => "all"
162
+ }
163
+ end
164
+
165
+ def price(product)
166
+ product["cost"].to_f
167
+ end
168
+ end
169
+
170
+ class International < RateRequest
171
+
172
+ def rates
173
+ raise "New Zealand Post packages must originate in New Zealand" unless new_zealand_origin?
174
+ super
175
+ end
176
+
177
+ def service_name(product)
178
+ [ product["group"], product["name"] ].join(" ")
179
+ end
180
+
181
+ def api
182
+ :international
183
+ end
184
+
185
+ def api_params
186
+ { :country_code => @destination.country_code }
187
+ end
188
+
189
+ def price(product)
190
+ product["price"].to_f
191
+ end
192
+ end
193
+
194
+ class NewZealandPostPackage
195
+
196
+ def initialize(package, api)
197
+ @package = package
198
+ @api = api
199
+ @params = { :weight => weight, :length => length }
200
+ end
201
+
202
+ def params
203
+ @params.merge(api_params).merge(shape_params)
204
+ end
205
+
206
+ protected
207
+
208
+ def weight
209
+ @package.kg
210
+ end
211
+
212
+ def length
213
+ mm(:length)
214
+ end
215
+
216
+ def height
217
+ mm(:height)
218
+ end
219
+
220
+ def width
221
+ mm(:width)
222
+ end
223
+
224
+ def shape
225
+ return :cylinder if @package.cylinder?
226
+ :cuboid
227
+ end
228
+
229
+ def api_params
230
+ send("#{@api}_params")
231
+ end
232
+
233
+ def international_params
234
+ { :value => value }
235
+ end
236
+
237
+ def domestic_params
238
+ {}
239
+ end
240
+
241
+ def shape_params
242
+ send("#{shape}_params")
243
+ end
244
+
245
+ def cuboid_params
246
+ { :height => height, :thickness => width }
247
+ end
248
+
249
+ def cylinder_params
250
+ { :diameter => width }
251
+ end
252
+
253
+ def mm(measurement)
254
+ @package.cm(measurement) * 10
255
+ end
256
+
257
+ def value
258
+ return 0 unless @package.value && currency == "NZD"
259
+ @package.value / 100
260
+ end
261
+
262
+ def currency
263
+ @package.currency || "NZD"
264
+ end
265
+
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,178 @@
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 Username/EmailAddress and 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
+ :delivery_range => [ timestamp_from_business_day(quote["delivery_min"]),
120
+ timestamp_from_business_day(quote["delivery_max"]) ]
121
+ )
122
+ end
123
+ end
124
+
125
+ def carrier_for(service)
126
+ CARRIERS.dup.find{ |carrier| service.to_s =~ /^#{carrier}/i } || service.to_s.split(" ").first
127
+ end
128
+
129
+ def parse(xml)
130
+ response = {}
131
+ response["rates"] = []
132
+
133
+ document = REXML::Document.new(xml)
134
+
135
+ response["status"] = parse_child_text(document.root, "Status")
136
+
137
+ document.root.elements.each("Order/Quotes/Quote") do |e|
138
+ rate = {}
139
+ rate["method"] = e.attributes["method"]
140
+ rate["warehouse"] = parse_child_text(e, "Warehouse")
141
+ rate["service"] = parse_child_text(e, "Service")
142
+ rate["cost"] = parse_child_text(e, "Cost")
143
+ rate["currency"] = parse_child_attribute(e, "Cost", "currency")
144
+ if delivery_estimate = e.elements["DeliveryEstimate"]
145
+ rate["delivery_min"] = parse_child_text(delivery_estimate, "Minimum").to_i
146
+ rate["delivery_max"] = parse_child_text(delivery_estimate, "Maximum").to_i
147
+ end
148
+ response["rates"] << rate
149
+ end
150
+
151
+ if response["status"] == SUCCESS && response["rates"].any?
152
+ response["success"] = true
153
+ response["message"] = SUCCESS_MESSAGE
154
+ elsif response["status"] == SUCCESS && response["rates"].empty?
155
+ response["success"] = false
156
+ response["message"] = NO_RATES_MESSAGE
157
+ else
158
+ response["success"] = false
159
+ response["message"] = parse_child_text(document.root, "ErrorMessage")
160
+ end
161
+
162
+ response
163
+ end
164
+
165
+ def parse_child_text(parent, name)
166
+ if element = parent.elements[name]
167
+ element.text
168
+ end
169
+ end
170
+
171
+ def parse_child_attribute(parent, name, attribute)
172
+ if element = parent.elements[name]
173
+ element.attributes[attribute]
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,452 @@
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://onlinetools.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
+ TRACKING_STATUS_CODES = HashWithIndifferentAccess.new({
90
+ 'I' => :in_transit,
91
+ 'D' => :delivered,
92
+ 'X' => :exception,
93
+ 'P' => :pickup,
94
+ 'M' => :manifest_pickup
95
+ })
96
+
97
+ # From http://en.wikipedia.org/w/index.php?title=European_Union&oldid=174718707 (Current as of November 30, 2007)
98
+ 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"]
99
+
100
+ US_TERRITORIES_TREATED_AS_COUNTRIES = ["AS", "FM", "GU", "MH", "MP", "PW", "PR", "VI"]
101
+
102
+ def requirements
103
+ [:key, :login, :password]
104
+ end
105
+
106
+ def find_rates(origin, destination, packages, options={})
107
+ origin, destination = upsified_location(origin), upsified_location(destination)
108
+ options = @options.merge(options)
109
+ packages = Array(packages)
110
+ access_request = build_access_request
111
+ rate_request = build_rate_request(origin, destination, packages, options)
112
+ response = commit(:rates, save_request(access_request + rate_request), (options[:test] || false))
113
+ parse_rate_response(origin, destination, packages, response, options)
114
+ end
115
+
116
+ def find_tracking_info(tracking_number, options={})
117
+ options = @options.update(options)
118
+ access_request = build_access_request
119
+ tracking_request = build_tracking_request(tracking_number, options)
120
+ response = commit(:track, save_request(access_request + tracking_request), (options[:test] || false))
121
+ parse_tracking_response(response, options)
122
+ end
123
+
124
+ protected
125
+
126
+ def upsified_location(location)
127
+ if location.country_code == 'US' && US_TERRITORIES_TREATED_AS_COUNTRIES.include?(location.state)
128
+ atts = {:country => location.state}
129
+ [:zip, :city, :address1, :address2, :address3, :phone, :fax, :address_type].each do |att|
130
+ atts[att] = location.send(att)
131
+ end
132
+ Location.new(atts)
133
+ else
134
+ location
135
+ end
136
+ end
137
+
138
+ def build_access_request
139
+ xml_request = XmlNode.new('AccessRequest') do |access_request|
140
+ access_request << XmlNode.new('AccessLicenseNumber', @options[:key])
141
+ access_request << XmlNode.new('UserId', @options[:login])
142
+ access_request << XmlNode.new('Password', @options[:password])
143
+ end
144
+ xml_request.to_s
145
+ end
146
+
147
+ def build_rate_request(origin, destination, packages, options={})
148
+ packages = Array(packages)
149
+ xml_request = XmlNode.new('RatingServiceSelectionRequest') do |root_node|
150
+ root_node << XmlNode.new('Request') do |request|
151
+ request << XmlNode.new('RequestAction', 'Rate')
152
+ request << XmlNode.new('RequestOption', 'Shop')
153
+ # not implemented: 'Rate' RequestOption to specify a single service query
154
+ # request << XmlNode.new('RequestOption', ((options[:service].nil? or options[:service] == :all) ? 'Shop' : 'Rate'))
155
+ end
156
+
157
+ pickup_type = options[:pickup_type] || :daily_pickup
158
+
159
+ root_node << XmlNode.new('PickupType') do |pickup_type_node|
160
+ pickup_type_node << XmlNode.new('Code', PICKUP_CODES[pickup_type])
161
+ # not implemented: PickupType/PickupDetails element
162
+ end
163
+ cc = options[:customer_classification] || DEFAULT_CUSTOMER_CLASSIFICATIONS[pickup_type]
164
+ root_node << XmlNode.new('CustomerClassification') do |cc_node|
165
+ cc_node << XmlNode.new('Code', CUSTOMER_CLASSIFICATIONS[cc])
166
+ end
167
+
168
+ root_node << XmlNode.new('Shipment') do |shipment|
169
+ # not implemented: Shipment/Description element
170
+ shipment << build_location_node('Shipper', (options[:shipper] || origin), options)
171
+ shipment << build_location_node('ShipTo', destination, options)
172
+ if options[:shipper] and options[:shipper] != origin
173
+ shipment << build_location_node('ShipFrom', origin, options)
174
+ end
175
+
176
+ # not implemented: * Shipment/ShipmentWeight element
177
+ # * Shipment/ReferenceNumber element
178
+ # * Shipment/Service element
179
+ # * Shipment/PickupDate element
180
+ # * Shipment/ScheduledDeliveryDate element
181
+ # * Shipment/ScheduledDeliveryTime element
182
+ # * Shipment/AlternateDeliveryTime element
183
+ # * Shipment/DocumentsOnly element
184
+
185
+ packages.each do |package|
186
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
187
+
188
+ shipment << XmlNode.new("Package") do |package_node|
189
+
190
+ # not implemented: * Shipment/Package/PackagingType element
191
+ # * Shipment/Package/Description element
192
+
193
+ package_node << XmlNode.new("PackagingType") do |packaging_type|
194
+ packaging_type << XmlNode.new("Code", '02')
195
+ end
196
+
197
+ package_node << XmlNode.new("Dimensions") do |dimensions|
198
+ dimensions << XmlNode.new("UnitOfMeasurement") do |units|
199
+ units << XmlNode.new("Code", imperial ? 'IN' : 'CM')
200
+ end
201
+ [:length,:width,:height].each do |axis|
202
+ value = ((imperial ? package.inches(axis) : package.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
203
+ dimensions << XmlNode.new(axis.to_s.capitalize, [value,0.1].max)
204
+ end
205
+ end
206
+
207
+ package_node << XmlNode.new("PackageWeight") do |package_weight|
208
+ package_weight << XmlNode.new("UnitOfMeasurement") do |units|
209
+ units << XmlNode.new("Code", imperial ? 'LBS' : 'KGS')
210
+ end
211
+
212
+ value = ((imperial ? package.lbs : package.kgs).to_f*1000).round/1000.0 # 3 decimals
213
+ package_weight << XmlNode.new("Weight", [value,0.1].max)
214
+ end
215
+
216
+ # not implemented: * Shipment/Package/LargePackageIndicator element
217
+ # * Shipment/Package/ReferenceNumber element
218
+ # * Shipment/Package/PackageServiceOptions element
219
+ # * Shipment/Package/AdditionalHandling element
220
+ end
221
+
222
+ end
223
+
224
+ # not implemented: * Shipment/ShipmentServiceOptions element
225
+ # * Shipment/RateInformation element
226
+
227
+ end
228
+
229
+ end
230
+ xml_request.to_s
231
+ end
232
+
233
+ def build_tracking_request(tracking_number, options={})
234
+ xml_request = XmlNode.new('TrackRequest') do |root_node|
235
+ root_node << XmlNode.new('Request') do |request|
236
+ request << XmlNode.new('RequestAction', 'Track')
237
+ request << XmlNode.new('RequestOption', '1')
238
+ end
239
+ root_node << XmlNode.new('TrackingNumber', tracking_number.to_s)
240
+ end
241
+ xml_request.to_s
242
+ end
243
+
244
+ def build_location_node(name,location,options={})
245
+ # not implemented: * Shipment/Shipper/Name element
246
+ # * Shipment/(ShipTo|ShipFrom)/CompanyName element
247
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/AttentionName element
248
+ # * Shipment/(Shipper|ShipTo|ShipFrom)/TaxIdentificationNumber element
249
+ location_node = XmlNode.new(name) do |location_node|
250
+ location_node << XmlNode.new('PhoneNumber', location.phone.gsub(/[^\d]/,'')) unless location.phone.blank?
251
+ location_node << XmlNode.new('FaxNumber', location.fax.gsub(/[^\d]/,'')) unless location.fax.blank?
252
+
253
+ if name == 'Shipper' and (origin_account = @options[:origin_account] || options[:origin_account])
254
+ location_node << XmlNode.new('ShipperNumber', origin_account)
255
+ elsif name == 'ShipTo' and (destination_account = @options[:destination_account] || options[:destination_account])
256
+ location_node << XmlNode.new('ShipperAssignedIdentificationNumber', destination_account)
257
+ end
258
+
259
+ location_node << XmlNode.new('Address') do |address|
260
+ address << XmlNode.new("AddressLine1", location.address1) unless location.address1.blank?
261
+ address << XmlNode.new("AddressLine2", location.address2) unless location.address2.blank?
262
+ address << XmlNode.new("AddressLine3", location.address3) unless location.address3.blank?
263
+ address << XmlNode.new("City", location.city) unless location.city.blank?
264
+ address << XmlNode.new("StateProvinceCode", location.province) unless location.province.blank?
265
+ # StateProvinceCode required for negotiated rates but not otherwise, for some reason
266
+ address << XmlNode.new("PostalCode", location.postal_code) unless location.postal_code.blank?
267
+ address << XmlNode.new("CountryCode", location.country_code(:alpha2)) unless location.country_code(:alpha2).blank?
268
+ 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
269
+ # not implemented: Shipment/(Shipper|ShipTo|ShipFrom)/Address/ResidentialAddressIndicator element
270
+ end
271
+ end
272
+ end
273
+
274
+ def parse_rate_response(origin, destination, packages, response, options={})
275
+ rates = []
276
+
277
+ xml = REXML::Document.new(response)
278
+ success = response_success?(xml)
279
+ message = response_message(xml)
280
+
281
+ if success
282
+ rate_estimates = []
283
+
284
+ xml.elements.each('/*/RatedShipment') do |rated_shipment|
285
+ service_code = rated_shipment.get_text('Service/Code').to_s
286
+ days_to_delivery = rated_shipment.get_text('GuaranteedDaysToDelivery').to_s.to_i
287
+ days_to_delivery = nil if days_to_delivery == 0
288
+
289
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
290
+ service_name_for(origin, service_code),
291
+ :total_price => rated_shipment.get_text('TotalCharges/MonetaryValue').to_s.to_f,
292
+ :currency => rated_shipment.get_text('TotalCharges/CurrencyCode').to_s,
293
+ :service_code => service_code,
294
+ :packages => packages,
295
+ :delivery_range => [timestamp_from_business_day(days_to_delivery)])
296
+ end
297
+ end
298
+ RateResponse.new(success, message, Hash.from_xml(response).values.first, :rates => rate_estimates, :xml => response, :request => last_request)
299
+ end
300
+
301
+ def parse_tracking_response(response, options={})
302
+ xml = REXML::Document.new(response)
303
+ success = response_success?(xml)
304
+ message = response_message(xml)
305
+
306
+ if success
307
+ tracking_number, origin, destination, status_code, status_description = nil
308
+ delivered, exception = false
309
+ exception_event = nil
310
+ shipment_events = []
311
+ status = {}
312
+ scheduled_delivery_date = nil
313
+
314
+ first_shipment = xml.elements['/*/Shipment']
315
+ first_package = first_shipment.elements['Package']
316
+ tracking_number = first_shipment.get_text('ShipmentIdentificationNumber | Package/TrackingNumber').to_s
317
+
318
+ # Build status hash
319
+ status_node = first_package.elements['Activity/Status/StatusType']
320
+ status_code = status_node.get_text('Code').to_s
321
+ status_description = status_node.get_text('Description').to_s
322
+ status = TRACKING_STATUS_CODES[status_code]
323
+
324
+ if status_description =~ /out.*delivery/i
325
+ status = :out_for_delivery
326
+ end
327
+
328
+ origin, destination = %w{Shipper ShipTo}.map do |location|
329
+ location_from_address_node(first_shipment.elements["#{location}/Address"])
330
+ end
331
+
332
+ # Get scheduled delivery date
333
+ unless status == :delivered
334
+ scheduled_delivery_date = parse_ups_datetime({
335
+ :date => first_shipment.get_text('ScheduledDeliveryDate'),
336
+ :time => nil
337
+ })
338
+ end
339
+
340
+ activities = first_package.get_elements('Activity')
341
+ unless activities.empty?
342
+ shipment_events = activities.map do |activity|
343
+ description = activity.get_text('Status/StatusType/Description').to_s
344
+ zoneless_time = if (time = activity.get_text('Time')) &&
345
+ (date = activity.get_text('Date'))
346
+ time, date = time.to_s, date.to_s
347
+ hour, minute, second = time.scan(/\d{2}/)
348
+ year, month, day = date[0..3], date[4..5], date[6..7]
349
+ Time.utc(year, month, day, hour, minute, second)
350
+ end
351
+ location = location_from_address_node(activity.elements['ActivityLocation/Address'])
352
+ ShipmentEvent.new(description, zoneless_time, location)
353
+ end
354
+
355
+ shipment_events = shipment_events.sort_by(&:time)
356
+
357
+ # UPS will sometimes archive a shipment, stripping all shipment activity except for the delivery
358
+ # event (see test/fixtures/xml/delivered_shipment_without_events_tracking_response.xml for an example).
359
+ # This adds an origin event to the shipment activity in such cases.
360
+ if origin && !(shipment_events.count == 1 && status == :delivered)
361
+ first_event = shipment_events[0]
362
+ same_country = origin.country_code(:alpha2) == first_event.location.country_code(:alpha2)
363
+ same_or_blank_city = first_event.location.city.blank? or first_event.location.city == origin.city
364
+ origin_event = ShipmentEvent.new(first_event.name, first_event.time, origin)
365
+ if same_country and same_or_blank_city
366
+ shipment_events[0] = origin_event
367
+ else
368
+ shipment_events.unshift(origin_event)
369
+ end
370
+ end
371
+
372
+ # Has the shipment been delivered?
373
+ if status == :delivered
374
+ if !destination
375
+ destination = shipment_events[-1].location
376
+ end
377
+ shipment_events[-1] = ShipmentEvent.new(shipment_events.last.name, shipment_events.last.time, destination)
378
+ end
379
+ end
380
+
381
+ end
382
+ TrackingResponse.new(success, message, Hash.from_xml(response).values.first,
383
+ :carrier => @@name,
384
+ :xml => response,
385
+ :request => last_request,
386
+ :status => status,
387
+ :status_code => status_code,
388
+ :status_description => status_description,
389
+ :scheduled_delivery_date => scheduled_delivery_date,
390
+ :shipment_events => shipment_events,
391
+ :delivered => delivered,
392
+ :exception => exception,
393
+ :exception_event => exception_event,
394
+ :origin => origin,
395
+ :destination => destination,
396
+ :tracking_number => tracking_number)
397
+ end
398
+
399
+ def location_from_address_node(address)
400
+ return nil unless address
401
+ Location.new(
402
+ :country => node_text_or_nil(address.elements['CountryCode']),
403
+ :postal_code => node_text_or_nil(address.elements['PostalCode']),
404
+ :province => node_text_or_nil(address.elements['StateProvinceCode']),
405
+ :city => node_text_or_nil(address.elements['City']),
406
+ :address1 => node_text_or_nil(address.elements['AddressLine1']),
407
+ :address2 => node_text_or_nil(address.elements['AddressLine2']),
408
+ :address3 => node_text_or_nil(address.elements['AddressLine3'])
409
+ )
410
+ end
411
+
412
+ def parse_ups_datetime(options = {})
413
+ time, date = options[:time].to_s, options[:date].to_s
414
+ if time.nil?
415
+ hour, minute, second = 0
416
+ else
417
+ hour, minute, second = time.scan(/\d{2}/)
418
+ end
419
+ year, month, day = date[0..3], date[4..5], date[6..7]
420
+
421
+ Time.utc(year, month, day, hour, minute, second)
422
+ end
423
+
424
+ def response_success?(xml)
425
+ xml.get_text('/*/Response/ResponseStatusCode').to_s == '1'
426
+ end
427
+
428
+ def response_message(xml)
429
+ xml.get_text('/*/Response/Error/ErrorDescription | /*/Response/ResponseStatusDescription').to_s
430
+ end
431
+
432
+ def commit(action, request, test = false)
433
+ ssl_post("#{test ? TEST_URL : LIVE_URL}/#{RESOURCES[action]}", request)
434
+ end
435
+
436
+
437
+ def service_name_for(origin, code)
438
+ origin = origin.country_code(:alpha2)
439
+
440
+ name = case origin
441
+ when "CA" then CANADA_ORIGIN_SERVICES[code]
442
+ when "MX" then MEXICO_ORIGIN_SERVICES[code]
443
+ when *EU_COUNTRY_CODES then EU_ORIGIN_SERVICES[code]
444
+ end
445
+
446
+ name ||= OTHER_NON_US_ORIGIN_SERVICES[code] unless name == 'US'
447
+ name ||= DEFAULT_SERVICES[code]
448
+ end
449
+
450
+ end
451
+ end
452
+ end