active_shipping 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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