active_shipping 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. data/.gitignore +6 -0
  2. data/CHANGELOG +23 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.markdown +173 -0
  5. data/Rakefile +55 -0
  6. data/VERSION +1 -0
  7. data/init.rb +1 -0
  8. data/lib/active_shipping.rb +50 -0
  9. data/lib/active_shipping/lib/connection.rb +170 -0
  10. data/lib/active_shipping/lib/country.rb +319 -0
  11. data/lib/active_shipping/lib/error.rb +4 -0
  12. data/lib/active_shipping/lib/post_data.rb +22 -0
  13. data/lib/active_shipping/lib/posts_data.rb +47 -0
  14. data/lib/active_shipping/lib/requires_parameters.rb +16 -0
  15. data/lib/active_shipping/lib/utils.rb +18 -0
  16. data/lib/active_shipping/lib/validateable.rb +76 -0
  17. data/lib/active_shipping/shipping/base.rb +15 -0
  18. data/lib/active_shipping/shipping/carrier.rb +75 -0
  19. data/lib/active_shipping/shipping/carriers.rb +17 -0
  20. data/lib/active_shipping/shipping/carriers/bogus_carrier.rb +16 -0
  21. data/lib/active_shipping/shipping/carriers/fedex.rb +315 -0
  22. data/lib/active_shipping/shipping/carriers/shipwire.rb +167 -0
  23. data/lib/active_shipping/shipping/carriers/ups.rb +368 -0
  24. data/lib/active_shipping/shipping/carriers/usps.rb +496 -0
  25. data/lib/active_shipping/shipping/location.rb +100 -0
  26. data/lib/active_shipping/shipping/package.rb +144 -0
  27. data/lib/active_shipping/shipping/rate_estimate.rb +54 -0
  28. data/lib/active_shipping/shipping/rate_response.rb +19 -0
  29. data/lib/active_shipping/shipping/response.rb +49 -0
  30. data/lib/active_shipping/shipping/shipment_event.rb +14 -0
  31. data/lib/active_shipping/shipping/tracking_response.rb +22 -0
  32. data/lib/certs/cacert.pem +7815 -0
  33. data/lib/vendor/quantified/MIT-LICENSE +22 -0
  34. data/lib/vendor/quantified/README.markdown +49 -0
  35. data/lib/vendor/quantified/Rakefile +21 -0
  36. data/lib/vendor/quantified/init.rb +0 -0
  37. data/lib/vendor/quantified/lib/quantified.rb +6 -0
  38. data/lib/vendor/quantified/lib/quantified/attribute.rb +208 -0
  39. data/lib/vendor/quantified/lib/quantified/length.rb +20 -0
  40. data/lib/vendor/quantified/lib/quantified/mass.rb +19 -0
  41. data/lib/vendor/quantified/test/length_test.rb +92 -0
  42. data/lib/vendor/quantified/test/mass_test.rb +88 -0
  43. data/lib/vendor/quantified/test/test_helper.rb +2 -0
  44. data/lib/vendor/test_helper.rb +13 -0
  45. data/lib/vendor/xml_node/README +36 -0
  46. data/lib/vendor/xml_node/Rakefile +21 -0
  47. data/lib/vendor/xml_node/benchmark/bench_generation.rb +32 -0
  48. data/lib/vendor/xml_node/init.rb +1 -0
  49. data/lib/vendor/xml_node/lib/xml_node.rb +222 -0
  50. data/lib/vendor/xml_node/test/test_generating.rb +94 -0
  51. data/lib/vendor/xml_node/test/test_parsing.rb +43 -0
  52. data/test/fixtures.yml +13 -0
  53. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_request.xml +67 -0
  54. data/test/fixtures/xml/fedex/ottawa_to_beverly_hills_rate_response.xml +213 -0
  55. data/test/fixtures/xml/fedex/tracking_request.xml +27 -0
  56. data/test/fixtures/xml/fedex/tracking_response.xml +153 -0
  57. data/test/fixtures/xml/shipwire/international_rates_response.xml +17 -0
  58. data/test/fixtures/xml/shipwire/invalid_credentials_response.xml +4 -0
  59. data/test/fixtures/xml/shipwire/new_carrier_rate_response.xml +18 -0
  60. data/test/fixtures/xml/shipwire/no_rates_response.xml +7 -0
  61. data/test/fixtures/xml/shipwire/rates_response.xml +36 -0
  62. data/test/fixtures/xml/ups/example_tracking_response.xml +53 -0
  63. data/test/fixtures/xml/ups/shipment_from_tiger_direct.xml +222 -0
  64. data/test/fixtures/xml/ups/test_real_home_as_residential_destination_response.xml +1 -0
  65. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_book_rate_response.xml +85 -0
  66. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_book_wii_rate_response.xml +168 -0
  67. data/test/fixtures/xml/usps/beverly_hills_to_ottawa_wii_rate_response.xml +85 -0
  68. data/test/remote/fedex_test.rb +140 -0
  69. data/test/remote/shipwire_test.rb +88 -0
  70. data/test/remote/ups_test.rb +187 -0
  71. data/test/remote/usps_test.rb +184 -0
  72. data/test/test_helper.rb +167 -0
  73. data/test/unit/base_test.rb +18 -0
  74. data/test/unit/carriers/fedex_test.rb +78 -0
  75. data/test/unit/carriers/shipwire_test.rb +130 -0
  76. data/test/unit/carriers/ups_test.rb +81 -0
  77. data/test/unit/carriers/usps_test.rb +206 -0
  78. data/test/unit/location_test.rb +46 -0
  79. data/test/unit/package_test.rb +65 -0
  80. data/test/unit/response_test.rb +10 -0
  81. metadata +158 -0
@@ -0,0 +1,15 @@
1
+ module ActiveMerchant
2
+ module Shipping
3
+ module Base
4
+ mattr_accessor :mode
5
+ self.mode = :production
6
+
7
+ ALLCAPS_NAMES = ['ups','usps','dhl'] # is the class name allcaps like USPS or camelcase like FedEx?
8
+
9
+ def self.carrier(name)
10
+ name = name.to_s.downcase
11
+ ActiveMerchant::Shipping.const_get(ALLCAPS_NAMES.include?(name) ? name.upcase : name.camelize)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,75 @@
1
+ module ActiveMerchant
2
+ module Shipping
3
+ class Carrier
4
+
5
+ include RequiresParameters
6
+ include PostsData
7
+ include Quantified
8
+
9
+ attr_reader :last_request
10
+ attr_accessor :test_mode
11
+ alias_method :test_mode?, :test_mode
12
+
13
+ # Credentials should be in options hash under keys :login, :password and/or :key.
14
+ def initialize(options = {})
15
+ requirements.each {|key| requires!(options, key)}
16
+ @options = options
17
+ @last_request = nil
18
+ @test_mode = @options[:test]
19
+ end
20
+
21
+ # Override to return required keys in options hash for initialize method.
22
+ def requirements
23
+ []
24
+ end
25
+
26
+ # Override with whatever you need to get the rates
27
+ def find_rates(origin, destination, packages, options = {})
28
+ end
29
+
30
+ # Validate credentials with a call to the API. By default this just does a find_rates call
31
+ # with the orgin and destination both as the carrier's default_location. Override to provide
32
+ # alternate functionality, such as checking for test_mode to use test servers, etc.
33
+ def valid_credentials?
34
+ location = self.class.default_location
35
+ find_rates(location,location,Package.new(100, [5,15,30]))
36
+ rescue ActiveMerchant::Shipping::ResponseError
37
+ false
38
+ else
39
+ true
40
+ end
41
+
42
+ def maximum_weight
43
+ Mass.new(150, :pounds)
44
+ end
45
+
46
+ protected
47
+
48
+ def node_string_or_nil(xml_node)
49
+ text = node_text_or_nil(xml_node)
50
+ text ? text.to_s : nil
51
+ end
52
+
53
+ def node_text_or_nil(xml_node)
54
+ xml_node ? xml_node.text : nil
55
+ end
56
+
57
+ # Override in subclasses for non-U.S.-based carriers.
58
+ def self.default_location
59
+ Location.new( :country => 'US',
60
+ :state => 'CA',
61
+ :city => 'Beverly Hills',
62
+ :address1 => '455 N. Rexford Dr.',
63
+ :address2 => '3rd Floor',
64
+ :zip => '90210',
65
+ :phone => '1-310-285-1013',
66
+ :fax => '1-310-275-8159')
67
+ end
68
+
69
+ # Use after building the request to save for later inspection. Probably won't ever be overridden.
70
+ def save_request(r)
71
+ @last_request = r
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,17 @@
1
+ require 'active_shipping/shipping/carriers/bogus_carrier'
2
+ require 'active_shipping/shipping/carriers/ups'
3
+ require 'active_shipping/shipping/carriers/usps'
4
+ require 'active_shipping/shipping/carriers/fedex'
5
+ require 'active_shipping/shipping/carriers/shipwire'
6
+
7
+ module ActiveMerchant
8
+ module Shipping
9
+ module Carriers
10
+ class <<self
11
+ def all
12
+ [BogusCarrier, UPS, USPS, FedEx]
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveMerchant
2
+ module Shipping
3
+ class BogusCarrier < Carrier
4
+ cattr_reader :name
5
+ @@name = "Bogus Carrier"
6
+
7
+
8
+ def find_rates(origin, destination, packages, options = {})
9
+ origin = Location.from(origin)
10
+ destination = Location.from(destination)
11
+ packages = Array(packages)
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,315 @@
1
+ # FedEx module by Jimmy Baker
2
+ # http://github.com/jimmyebaker
3
+
4
+ module ActiveMerchant
5
+ module Shipping
6
+
7
+ # :key is your developer API key
8
+ # :password is your API password
9
+ # :account is your FedEx account number
10
+ # :login is your meter number
11
+ class FedEx < Carrier
12
+ self.retry_safe = true
13
+
14
+ cattr_reader :name
15
+ @@name = "FedEx"
16
+
17
+ TEST_URL = 'https://gatewaybeta.fedex.com:443/xml'
18
+ LIVE_URL = 'https://gateway.fedex.com:443/xml'
19
+
20
+ CarrierCodes = {
21
+ "fedex_ground" => "FDXG",
22
+ "fedex_express" => "FDXE"
23
+ }
24
+
25
+ ServiceTypes = {
26
+ "PRIORITY_OVERNIGHT" => "FedEx Priority Overnight",
27
+ "PRIORITY_OVERNIGHT_SATURDAY_DELIVERY" => "FedEx Priority Overnight Saturday Delivery",
28
+ "FEDEX_2_DAY" => "FedEx 2 Day",
29
+ "FEDEX_2_DAY_SATURDAY_DELIVERY" => "FedEx 2 Day Saturday Delivery",
30
+ "STANDARD_OVERNIGHT" => "FedEx Standard Overnight",
31
+ "FIRST_OVERNIGHT" => "FedEx First Overnight",
32
+ "FEDEX_EXPRESS_SAVER" => "FedEx Express Saver",
33
+ "FEDEX_1_DAY_FREIGHT" => "FedEx 1 Day Freight",
34
+ "FEDEX_1_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 1 Day Freight Saturday Delivery",
35
+ "FEDEX_2_DAY_FREIGHT" => "FedEx 2 Day Freight",
36
+ "FEDEX_2_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 2 Day Freight Saturday Delivery",
37
+ "FEDEX_3_DAY_FREIGHT" => "FedEx 3 Day Freight",
38
+ "FEDEX_3_DAY_FREIGHT_SATURDAY_DELIVERY" => "FedEx 3 Day Freight Saturday Delivery",
39
+ "INTERNATIONAL_PRIORITY" => "FedEx International Priority",
40
+ "INTERNATIONAL_PRIORITY_SATURDAY_DELIVERY" => "FedEx International Priority Saturday Delivery",
41
+ "INTERNATIONAL_ECONOMY" => "FedEx International Economy",
42
+ "INTERNATIONAL_FIRST" => "FedEx International First",
43
+ "INTERNATIONAL_PRIORITY_FREIGHT" => "FedEx International Priority Freight",
44
+ "INTERNATIONAL_ECONOMY_FREIGHT" => "FedEx International Economy Freight",
45
+ "GROUND_HOME_DELIVERY" => "FedEx Ground Home Delivery",
46
+ "FEDEX_GROUND" => "FedEx Ground",
47
+ "INTERNATIONAL_GROUND" => "FedEx International Ground"
48
+ }
49
+
50
+ PackageTypes = {
51
+ "fedex_envelope" => "FEDEX_ENVELOPE",
52
+ "fedex_pak" => "FEDEX_PAK",
53
+ "fedex_box" => "FEDEX_BOX",
54
+ "fedex_tube" => "FEDEX_TUBE",
55
+ "fedex_10_kg_box" => "FEDEX_10KG_BOX",
56
+ "fedex_25_kg_box" => "FEDEX_25KG_BOX",
57
+ "your_packaging" => "YOUR_PACKAGING"
58
+ }
59
+
60
+ DropoffTypes = {
61
+ 'regular_pickup' => 'REGULAR_PICKUP',
62
+ 'request_courier' => 'REQUEST_COURIER',
63
+ 'dropbox' => 'DROP_BOX',
64
+ 'business_service_center' => 'BUSINESS_SERVICE_CENTER',
65
+ 'station' => 'STATION'
66
+ }
67
+
68
+ PaymentTypes = {
69
+ 'sender' => 'SENDER',
70
+ 'recipient' => 'RECIPIENT',
71
+ 'third_party' => 'THIRDPARTY',
72
+ 'collect' => 'COLLECT'
73
+ }
74
+
75
+ PackageIdentifierTypes = {
76
+ 'tracking_number' => 'TRACKING_NUMBER_OR_DOORTAG',
77
+ 'door_tag' => 'TRACKING_NUMBER_OR_DOORTAG',
78
+ 'rma' => 'RMA',
79
+ 'ground_shipment_id' => 'GROUND_SHIPMENT_ID',
80
+ 'ground_invoice_number' => 'GROUND_INVOICE_NUMBER',
81
+ 'ground_customer_reference' => 'GROUND_CUSTOMER_REFERENCE',
82
+ 'ground_po' => 'GROUND_PO',
83
+ 'express_reference' => 'EXPRESS_REFERENCE',
84
+ 'express_mps_master' => 'EXPRESS_MPS_MASTER'
85
+ }
86
+
87
+ def requirements
88
+ [:key, :password, :account, :login]
89
+ end
90
+
91
+ def find_rates(origin, destination, packages, options = {})
92
+ options = @options.update(options)
93
+ packages = Array(packages)
94
+
95
+ rate_request = build_rate_request(origin, destination, packages, options)
96
+
97
+ response = commit(save_request(rate_request), (options[:test] || false)).gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
98
+
99
+ parse_rate_response(origin, destination, packages, response, options)
100
+ end
101
+
102
+ def find_tracking_info(tracking_number, options={})
103
+ options = @options.update(options)
104
+
105
+ tracking_request = build_tracking_request(tracking_number, options)
106
+ response = commit(save_request(tracking_request), (options[:test] || false)).gsub(/<(\/)?.*?\:(.*?)>/, '<\1\2>')
107
+ parse_tracking_response(response, options)
108
+ end
109
+
110
+ protected
111
+ def build_rate_request(origin, destination, packages, options={})
112
+ imperial = ['US','LR','MM'].include?(origin.country_code(:alpha2))
113
+
114
+ xml_request = XmlNode.new('RateRequest', 'xmlns' => 'http://fedex.com/ws/rate/v6') do |root_node|
115
+ root_node << build_request_header
116
+
117
+ # Version
118
+ root_node << XmlNode.new('Version') do |version_node|
119
+ version_node << XmlNode.new('ServiceId', 'crs')
120
+ version_node << XmlNode.new('Major', '6')
121
+ version_node << XmlNode.new('Intermediate', '0')
122
+ version_node << XmlNode.new('Minor', '0')
123
+ end
124
+
125
+ # Returns delivery dates
126
+ root_node << XmlNode.new('ReturnTransitAndCommit', true)
127
+ # Returns saturday delivery shipping options when available
128
+ root_node << XmlNode.new('VariableOptions', 'SATURDAY_DELIVERY')
129
+
130
+ root_node << XmlNode.new('RequestedShipment') do |rs|
131
+ rs << XmlNode.new('ShipTimestamp', Time.now)
132
+ rs << XmlNode.new('DropoffType', options[:dropoff_type] || 'REGULAR_PICKUP')
133
+ rs << XmlNode.new('PackagingType', options[:packaging_type] || 'YOUR_PACKAGING')
134
+
135
+ rs << build_location_node('Shipper', (options[:shipper] || origin))
136
+ rs << build_location_node('Recipient', destination)
137
+ if options[:shipper] and options[:shipper] != origin
138
+ rs << build_location_node('Origin', origin)
139
+ end
140
+
141
+ rs << XmlNode.new('RateRequestTypes', 'ACCOUNT')
142
+ rs << XmlNode.new('PackageCount', packages.size)
143
+ packages.each do |pkg|
144
+ rs << XmlNode.new('RequestedPackages') do |rps|
145
+ rps << XmlNode.new('Weight') do |tw|
146
+ tw << XmlNode.new('Units', imperial ? 'LB' : 'KG')
147
+ tw << XmlNode.new('Value', [((imperial ? pkg.lbs : pkg.kgs).to_f*1000).round/1000.0, 0.1].max)
148
+ end
149
+ rps << XmlNode.new('Dimensions') do |dimensions|
150
+ [:length,:width,:height].each do |axis|
151
+ value = ((imperial ? pkg.inches(axis) : pkg.cm(axis)).to_f*1000).round/1000.0 # 3 decimals
152
+ dimensions << XmlNode.new(axis.to_s.capitalize, value.ceil)
153
+ end
154
+ dimensions << XmlNode.new('Units', imperial ? 'IN' : 'CM')
155
+ end
156
+ end
157
+ end
158
+
159
+ end
160
+ end
161
+ xml_request.to_s
162
+ end
163
+
164
+ def build_tracking_request(tracking_number, options={})
165
+ xml_request = XmlNode.new('TrackRequest', 'xmlns' => 'http://fedex.com/ws/track/v3') do |root_node|
166
+ root_node << build_request_header
167
+
168
+ # Version
169
+ root_node << XmlNode.new('Version') do |version_node|
170
+ version_node << XmlNode.new('ServiceId', 'trck')
171
+ version_node << XmlNode.new('Major', '3')
172
+ version_node << XmlNode.new('Intermediate', '0')
173
+ version_node << XmlNode.new('Minor', '0')
174
+ end
175
+
176
+ root_node << XmlNode.new('PackageIdentifier') do |package_node|
177
+ package_node << XmlNode.new('Value', tracking_number)
178
+ package_node << XmlNode.new('Type', PackageIdentifierTypes[options['package_identifier_type'] || 'tracking_number'])
179
+ end
180
+
181
+ root_node << XmlNode.new('ShipDateRangeBegin', options['ship_date_range_begin']) if options['ship_date_range_begin']
182
+ root_node << XmlNode.new('ShipDateRangeEnd', options['ship_date_range_end']) if options['ship_date_range_end']
183
+ root_node << XmlNode.new('IncludeDetailedScans', 1)
184
+ end
185
+ xml_request.to_s
186
+ end
187
+
188
+ def build_request_header
189
+ web_authentication_detail = XmlNode.new('WebAuthenticationDetail') do |wad|
190
+ wad << XmlNode.new('UserCredential') do |uc|
191
+ uc << XmlNode.new('Key', @options[:key])
192
+ uc << XmlNode.new('Password', @options[:password])
193
+ end
194
+ end
195
+
196
+ client_detail = XmlNode.new('ClientDetail') do |cd|
197
+ cd << XmlNode.new('AccountNumber', @options[:account])
198
+ cd << XmlNode.new('MeterNumber', @options[:login])
199
+ end
200
+
201
+ trasaction_detail = XmlNode.new('TransactionDetail') do |td|
202
+ td << XmlNode.new('CustomerTransactionId', 'ActiveShipping') # TODO: Need to do something better with this..
203
+ end
204
+
205
+ [web_authentication_detail, client_detail, trasaction_detail]
206
+ end
207
+
208
+ def build_location_node(name, location)
209
+ location_node = XmlNode.new(name) do |xml_node|
210
+ xml_node << XmlNode.new('Address') do |address_node|
211
+ address_node << XmlNode.new('PostalCode', location.postal_code)
212
+ address_node << XmlNode.new("CountryCode", location.country_code(:alpha2))
213
+ end
214
+ end
215
+ end
216
+
217
+ def parse_rate_response(origin, destination, packages, response, options)
218
+ rate_estimates = []
219
+ success, message = nil
220
+
221
+ xml = REXML::Document.new(response)
222
+ root_node = xml.elements['RateReply']
223
+
224
+ success = response_success?(xml)
225
+ message = response_message(xml)
226
+
227
+ root_node.elements.each('RateReplyDetails') do |rated_shipment|
228
+ service_code = rated_shipment.get_text('ServiceType').to_s
229
+ is_saturday_delivery = rated_shipment.get_text('AppliedOptions').to_s == 'SATURDAY_DELIVERY'
230
+ service_type = is_saturday_delivery ? "#{rated_shipment.get_text('ServiceType').to_s}_SATURDAY_DELIVERY" : rated_shipment.get_text('ServiceType').to_s
231
+
232
+ rate_estimates << RateEstimate.new(origin, destination, @@name,
233
+ ServiceTypes[service_type],
234
+ :service_code => service_code,
235
+ :total_price => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Amount').to_s.to_f,
236
+ :currency => rated_shipment.get_text('RatedShipmentDetails/ShipmentRateDetail/TotalNetCharge/Currency').to_s,
237
+ :packages => packages,
238
+ :delivery_date => rated_shipment.get_text('DeliveryTimestamp').to_s)
239
+ end
240
+
241
+ if rate_estimates.empty?
242
+ success = false
243
+ message = "No shipping rates could be found for the destination address" if message.blank?
244
+ end
245
+
246
+ RateResponse.new(success, message, Hash.from_xml(response), :rates => rate_estimates, :xml => response, :request => last_request, :log_xml => options[:log_xml])
247
+ end
248
+
249
+ def parse_tracking_response(response, options)
250
+ xml = REXML::Document.new(response)
251
+ root_node = xml.elements['TrackReply']
252
+
253
+ success = response_success?(xml)
254
+ message = response_message(xml)
255
+
256
+ if success
257
+ tracking_number, origin, destination = nil
258
+ shipment_events = []
259
+
260
+ tracking_details = root_node.elements['TrackDetails']
261
+ tracking_number = tracking_details.get_text('TrackingNumber').to_s
262
+
263
+ destination_node = tracking_details.elements['DestinationAddress']
264
+ destination = Location.new(
265
+ :country => destination_node.get_text('CountryCode').to_s,
266
+ :province => destination_node.get_text('StateOrProvinceCode').to_s,
267
+ :city => destination_node.get_text('City').to_s
268
+ )
269
+
270
+ tracking_details.elements.each('Events') do |event|
271
+ location = Location.new(
272
+ :city => event.elements['Address'].get_text('City').to_s,
273
+ :state => event.elements['Address'].get_text('StateOrProvinceCode').to_s,
274
+ :postal_code => event.elements['Address'].get_text('PostalCode').to_s,
275
+ :country => event.elements['Address'].get_text('CountryCode').to_s)
276
+ description = event.get_text('EventDescription').to_s
277
+
278
+ # for now, just assume UTC, even though it probably isn't
279
+ time = Time.parse("#{event.get_text('Timestamp').to_s}")
280
+ zoneless_time = Time.utc(time.year, time.month, time.mday, time.hour, time.min, time.sec)
281
+
282
+ shipment_events << ShipmentEvent.new(description, zoneless_time, location)
283
+ end
284
+ shipment_events = shipment_events.sort_by(&:time)
285
+ end
286
+
287
+ TrackingResponse.new(success, message, Hash.from_xml(response),
288
+ :xml => response,
289
+ :request => last_request,
290
+ :shipment_events => shipment_events,
291
+ :destination => destination,
292
+ :tracking_number => tracking_number
293
+ )
294
+ end
295
+
296
+ def response_status_node(document)
297
+ document.elements['/*/Notifications/']
298
+ end
299
+
300
+ def response_success?(document)
301
+ %w{SUCCESS WARNING NOTE}.include? response_status_node(document).get_text('Severity').to_s
302
+ end
303
+
304
+ def response_message(document)
305
+ response_node = response_status_node(document)
306
+ "#{response_status_node(document).get_text('Severity').to_s} - #{response_node.get_text('Code').to_s}: #{response_node.get_text('Message').to_s}"
307
+ end
308
+
309
+ def commit(request, test = false)
310
+ ssl_post(test ? TEST_URL : LIVE_URL, request.gsub("\n",''))
311
+ end
312
+
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,167 @@
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
+ xml.tag! 'Address1', destination.address1
78
+ xml.tag! 'Address2', destination.address2 unless destination.address2.blank?
79
+ xml.tag! 'Address3', destination.address3 unless destination.address3.blank?
80
+ xml.tag! 'City', destination.city
81
+ xml.tag! 'State', destination.state unless destination.state.blank?
82
+ xml.tag! 'Country', destination.country_code
83
+ xml.tag! 'Zip', destination.zip unless destination.zip.blank?
84
+ end
85
+ end
86
+
87
+ # Code is limited to 12 characters
88
+ def add_item(xml, item, index)
89
+ xml.tag! 'Item', :num => index do
90
+ xml.tag! 'Code', item[:sku]
91
+ xml.tag! 'Quantity', item[:quantity]
92
+ end
93
+ end
94
+
95
+ def commit(origin, destination, options)
96
+ request = build_request(destination, options)
97
+ save_request(request)
98
+
99
+ response = parse( ssl_post(URL, "RateRequestXML=#{CGI.escape(request)}") )
100
+
101
+ RateResponse.new(response["success"], response["message"], response,
102
+ :xml => response,
103
+ :rates => build_rate_estimates(response, origin, destination),
104
+ :request => last_request
105
+ )
106
+ end
107
+
108
+ def build_rate_estimates(response, origin, destination)
109
+ response["rates"].collect do |quote|
110
+ RateEstimate.new(origin, destination, carrier_for(quote["service"]), quote["service"],
111
+ :service_code => quote["method"],
112
+ :total_price => quote["cost"],
113
+ :currency => quote["currency"]
114
+ )
115
+ end
116
+ end
117
+
118
+ def carrier_for(service)
119
+ CARRIERS.dup.find{ |carrier| service.to_s =~ /^#{carrier}/i } || service.to_s.split(" ").first
120
+ end
121
+
122
+ def parse(xml)
123
+ response = {}
124
+ response["rates"] = []
125
+
126
+ document = REXML::Document.new(xml)
127
+
128
+ response["status"] = parse_child_text(document.root, "Status")
129
+
130
+ document.root.elements.each("Order/Quotes/Quote") do |e|
131
+ rate = {}
132
+ rate["method"] = e.attributes["method"]
133
+ rate["warehouse"] = parse_child_text(e, "Warehouse")
134
+ rate["service"] = parse_child_text(e, "Service")
135
+ rate["cost"] = parse_child_text(e, "Cost")
136
+ rate["currency"] = parse_child_attribute(e, "Cost", "currency")
137
+ response["rates"] << rate
138
+ end
139
+
140
+ if response["status"] == SUCCESS && response["rates"].any?
141
+ response["success"] = true
142
+ response["message"] = SUCCESS_MESSAGE
143
+ elsif response["status"] == SUCCESS && response["rates"].empty?
144
+ response["success"] = false
145
+ response["message"] = NO_RATES_MESSAGE
146
+ else
147
+ response["success"] = false
148
+ response["message"] = parse_child_text(document.root, "ErrorMessage")
149
+ end
150
+
151
+ response
152
+ end
153
+
154
+ def parse_child_text(parent, name)
155
+ if element = parent.elements[name]
156
+ element.text
157
+ end
158
+ end
159
+
160
+ def parse_child_attribute(parent, name, attribute)
161
+ if element = parent.elements[name]
162
+ element.attributes[attribute]
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end