active_fulfillment 2.0.1 → 2.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_fulfillment.rb +4 -18
  3. data/lib/active_fulfillment/fulfillment/services/amazon_mws.rb +438 -0
  4. data/lib/active_fulfillment/fulfillment/services/shipwire.rb +39 -34
  5. data/lib/active_fulfillment/fulfillment/services/webgistix.rb +5 -1
  6. data/lib/active_fulfillment/fulfillment/version.rb +6 -0
  7. data/test/fixtures/xml/amazon_mws/fulfillment_get_fulfillment_order.xml +114 -0
  8. data/test/fixtures/xml/amazon_mws/fulfillment_get_fulfillment_order_2.xml +90 -0
  9. data/test/fixtures/xml/amazon_mws/fulfillment_get_fullfillment_order_with_multiple_tracking_numbers.xml +121 -0
  10. data/test/fixtures/xml/amazon_mws/fulfillment_list_all_fulfillment_orders.xml +70 -0
  11. data/test/fixtures/xml/amazon_mws/inventory_list_inventory_item_supply.xml +32 -0
  12. data/test/fixtures/xml/amazon_mws/inventory_list_inventory_supply.xml +75 -0
  13. data/test/fixtures/xml/amazon_mws/inventory_list_inventory_supply_by_next_token.xml +38 -0
  14. data/test/fixtures/xml/amazon_mws/tracking_response_error.xml +9 -0
  15. data/test/fixtures/xml/amazon_mws/tracking_response_not_found.xml +9 -0
  16. data/test/fixtures/xml/shipwire/fulfillment_failure_response.xml +7 -0
  17. data/test/fixtures/xml/shipwire/invalid_login_response.xml +7 -0
  18. data/test/fixtures/xml/shipwire/inventory_get_response.xml +44 -0
  19. data/test/fixtures/xml/shipwire/successful_empty_tracking_response.xml +8 -0
  20. data/test/fixtures/xml/shipwire/successful_live_tracking_response.xml +53 -0
  21. data/test/fixtures/xml/shipwire/successful_tracking_response.xml +16 -0
  22. data/test/fixtures/xml/shipwire/successful_tracking_response_with_tracking_urls.xml +31 -0
  23. data/test/fixtures/xml/webgistix/multiple_tracking_response.xml +21 -0
  24. data/test/remote/amazon_mws_test.rb +114 -0
  25. data/test/unit/services/amazon_mws_test.rb +357 -0
  26. data/test/unit/services/shipwire_test.rb +20 -95
  27. data/test/unit/services/webgistix_test.rb +12 -1
  28. metadata +57 -11
  29. data/Rakefile +0 -36
  30. data/VERSION +0 -1
  31. data/active_fulfillment.gemspec +0 -70
  32. data/init.rb +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8ddd23ea94dbb783740d4c3e79ee7b09b6dec541
4
- data.tar.gz: 0a2b1e403ca1d0b61eeb929145af060d2dfa1483
3
+ metadata.gz: 143a9457ed3cbe9faff1de7fe049ba1061421d5d
4
+ data.tar.gz: fd48163baf2ed6c6388a25d2af6812630a75cf6b
5
5
  SHA512:
6
- metadata.gz: 9ed73e2e6e5c9ab7231256d2eefd7c1c6fbed16c14a19b0d4c02bf2208739367a375e9a3d65365a8383651b5c797b715721d84a083603a2420d73b06f3c369e5
7
- data.tar.gz: 59168b043c3b489736cd80f7ca1540678f45cbd21a045850c0af21d8ba309d05b418c5897d62e0a1c62f7ccc1194abefb0b83e870489cac87e35278ee591cdd3
6
+ metadata.gz: ef2ab9127ce387ada96e6dcb90c43b660957a3c0d5fa9da36c4c5d66f6518bdf21c48fcf811b6e1da9e269c19528053db8c011306558694abe0d72e6f821daa7
7
+ data.tar.gz: 37ce0735dcfaff356284a72dca0d7b8aadea81b42630f4fb5d5a517e99111993b0a41ee5706c4dccaefa8acfba44631937604535240accbba41261c96699f946
@@ -20,17 +20,10 @@
20
20
  # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
21
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
22
  #++
23
-
24
- $:.unshift File.dirname(__FILE__)
25
-
26
- begin
27
- require 'active_support'
28
- rescue LoadError
29
- require 'rubygems'
30
- require 'active_support'
31
- end
32
23
 
24
+ require 'active_support'
33
25
  require 'active_support/core_ext/class/attribute'
26
+ require 'active_support/core_ext/module/attribute_accessors'
34
27
  require 'active_support/core_ext/class/delegating_attributes'
35
28
  require 'active_support/core_ext/time/calculations'
36
29
  require 'active_support/core_ext/date/calculations'
@@ -41,21 +34,14 @@ begin
41
34
  rescue LoadError
42
35
  end
43
36
 
44
- begin
45
- require 'builder'
46
- rescue LoadError
47
- require 'rubygems'
48
- require_gem 'builder'
49
- end
50
-
51
-
37
+ require 'builder'
52
38
  require 'cgi'
53
39
  require 'net/https'
54
40
  require 'rexml/document'
55
41
  require 'active_utils'
56
42
 
43
+ require 'active_fulfillment/fulfillment/version'
57
44
  require 'active_fulfillment/fulfillment/base'
58
45
  require 'active_fulfillment/fulfillment/response'
59
46
  require 'active_fulfillment/fulfillment/service'
60
47
  require 'active_fulfillment/fulfillment/services'
61
-
@@ -0,0 +1,438 @@
1
+ require 'base64'
2
+ require 'time'
3
+ require 'cgi'
4
+
5
+ module ActiveMerchant
6
+ module Fulfillment
7
+ class AmazonMarketplaceWebService < Service
8
+
9
+ APPLICATION_IDENTIFIER = "active_merchant_mws/0.01 (Language=ruby)"
10
+
11
+ REGISTRATION_URI = URI.parse("https://sellercentral.amazon.com/gp/mws/registration/register.html")
12
+
13
+ SIGNATURE_VERSION = 2
14
+ SIGNATURE_METHOD = "SHA256"
15
+ VERSION = "2010-10-01"
16
+
17
+ SUCCESS, FAILURE, ERROR = 'Accepted', 'Failure', 'Error'
18
+
19
+ MESSAGES = {
20
+ :status => {
21
+ 'Accepted' => 'Success',
22
+ 'Failure' => 'Failed',
23
+ 'Error' => 'An error occurred'
24
+ },
25
+ :create => {
26
+ 'Accepted' => 'Successfully submitted the order',
27
+ 'Failure' => 'Failed to submit the order',
28
+ 'Error' => 'An error occurred while submitting the order'
29
+ },
30
+ :list => {
31
+ 'Accepted' => 'Successfully submitted request',
32
+ 'Failure' => 'Failed to submit request',
33
+ 'Error' => 'An error occurred while submitting request'
34
+
35
+ }
36
+ }
37
+
38
+ ENDPOINTS = {
39
+ :ca => 'mws.amazonservices.ca',
40
+ :cn => 'mws.amazonservices.com.cn',
41
+ :de => 'mws-eu.amazonservices.ca',
42
+ :es => 'mws-eu.amazonservices.ca',
43
+ :fr => 'mws-eu.amazonservices.ca',
44
+ :it => 'mws-eu.amazonservices.ca',
45
+ :jp => 'mws.amazonservices.jp',
46
+ :uk => 'mws-eu.amazonservices.ca',
47
+ :us => 'mws.amazonservices.com'
48
+ }
49
+
50
+ LOOKUPS = {
51
+ :destination_address => {
52
+ :name => "DestinationAddress.Name",
53
+ :address1 => "DestinationAddress.Line1",
54
+ :address2 => "DestinationAddress.Line2",
55
+ :city => "DestinationAddress.City",
56
+ :state => "DestinationAddress.StateOrProvinceCode",
57
+ :country => "DestinationAddress.CountryCode",
58
+ :zip => "DestinationAddress.PostalCode",
59
+ :phone => "DestinationAddress.PhoneNumber"
60
+ },
61
+ :line_items => {
62
+ :comment => "Items.member.%d.DisplayableComment",
63
+ :gift_message => "Items.member.%d.GiftMessage",
64
+ :currency_code => "Items.member.%d.PerUnitDeclaredValue.CurrencyCode",
65
+ :value => "Items.member.%d.PerUnitDeclaredValue.Value",
66
+ :quantity => "Items.member.%d.Quantity",
67
+ :order_id => "Items.member.%d.SellerFulfillmentOrderItemId",
68
+ :sku => "Items.member.%d.SellerSKU",
69
+ :network_sku => "Items.member.%d.FulfillmentNetworkSKU",
70
+ :item_disposition => "Items.member.%d.OrderItemDisposition",
71
+ },
72
+ :list_inventory => {
73
+ :sku => "SellerSkus.member.%d"
74
+ }
75
+ }
76
+
77
+ ACTIONS = {
78
+ :outbound => "FulfillmentOutboundShipment",
79
+ :inventory => "FulfillmentInventory"
80
+ }
81
+
82
+ OPERATIONS = {
83
+ :outbound => {
84
+ :status => 'GetServiceStatus',
85
+ :create => 'CreateFulfillmentOrder',
86
+ :list => 'ListAllFulfillmentOrders',
87
+ :tracking => 'GetFulfillmentOrder'
88
+ },
89
+ :inventory => {
90
+ :get => 'ListInventorySupply',
91
+ :list => 'ListInventorySupply',
92
+ :list_next => 'ListInventorySupplyByNextToken'
93
+ }
94
+ }
95
+
96
+ # The first is the label, and the last is the code
97
+ # Standard: 3-5 business days
98
+ # Expedited: 2 business days
99
+ # Priority: 1 business day
100
+ def self.shipping_methods
101
+ [
102
+ [ 'Standard Shipping', 'Standard' ],
103
+ [ 'Expedited Shipping', 'Expedited' ],
104
+ [ 'Priority Shipping', 'Priority' ]
105
+ ].inject(ActiveSupport::OrderedHash.new){|h, (k,v)| h[k] = v; h}
106
+ end
107
+
108
+ def initialize(options = {})
109
+ requires!(options, :login, :password)
110
+ @seller_id = options[:seller_id]
111
+ super
112
+ end
113
+
114
+ def seller_id=(seller_id)
115
+ @seller_id = seller_id
116
+ end
117
+
118
+ def endpoint
119
+ ENDPOINTS[@options[:endpoint] || :us]
120
+ end
121
+
122
+ def fulfill(order_id, shipping_address, line_items, options = {})
123
+ requires!(options, :order_date, :shipping_method)
124
+ commit :post, :outbound, :create, build_fulfillment_request(order_id, shipping_address, line_items, options)
125
+ end
126
+
127
+ def status
128
+ commit :post, :outbound, :status, build_status_request
129
+ end
130
+
131
+ def fetch_current_orders
132
+ commit :post, :outbound, :status, build_get_current_fulfillment_orders_request
133
+ end
134
+
135
+ def fetch_stock_levels(options = {})
136
+ options[:skus] = [options.delete(:sku)] if options.include?(:sku)
137
+ response = commit :post, :inventory, :list, build_inventory_list_request(options)
138
+
139
+ while token = response.params['next_token'] do
140
+ next_page = commit :post, :inventory, :list_next, build_next_inventory_list_request(token)
141
+
142
+ next_page.stock_levels.merge!(response.stock_levels)
143
+ response = next_page
144
+ end
145
+
146
+ response
147
+ end
148
+
149
+ def fetch_tracking_numbers(order_ids, options = {})
150
+ order_ids.reduce(nil) do |previous, order_id|
151
+ response = commit :post, :outbound, :tracking, build_tracking_request(order_id, options)
152
+
153
+ if !response.success?
154
+ if response.faultstring.match(/^Requested order \'.+\' not found$/)
155
+ response = Response.new(true, nil, {
156
+ :status => SUCCESS,
157
+ :tracking_numbers => {}
158
+ })
159
+ else
160
+ return response
161
+ end
162
+ end
163
+
164
+ response.tracking_numbers.merge!(previous.tracking_numbers) if previous
165
+ response
166
+ end
167
+ end
168
+
169
+ def valid_credentials?
170
+ fetch_stock_levels.success?
171
+ end
172
+
173
+ def test_mode?
174
+ false
175
+ end
176
+
177
+ def build_full_query(verb, uri, params)
178
+ signature = sign(verb, uri, params)
179
+ build_query(params) + "&Signature=#{signature}"
180
+ end
181
+
182
+ def commit(verb, service, op, params)
183
+ uri = URI.parse("https://#{endpoint}/#{ACTIONS[service]}/#{VERSION}")
184
+ query = build_full_query(verb, uri, params)
185
+ headers = build_headers(query)
186
+
187
+ data = ssl_post(uri.to_s, query, headers)
188
+ response = parse_response(service, op, data)
189
+ Response.new(success?(response), message_from(response), response)
190
+ rescue ActiveMerchant::ResponseError => e
191
+ response = parse_error(e.response)
192
+ Response.new(false, message_from(response), response)
193
+ end
194
+
195
+ def success?(response)
196
+ response[:response_status] == SUCCESS
197
+ end
198
+
199
+ def message_from(response)
200
+ response[:response_message]
201
+ end
202
+
203
+ ## PARSING
204
+
205
+ def parse_response(service, op, xml)
206
+ begin
207
+ document = REXML::Document.new(xml)
208
+ rescue REXML::ParseException
209
+ return { :success => FAILURE }
210
+ end
211
+
212
+ case service
213
+ when :outbound
214
+ case op
215
+ when :tracking
216
+ parse_tracking_response(document)
217
+ else
218
+ parse_fulfillment_response(op, document)
219
+ end
220
+ when :inventory
221
+ parse_inventory_response(document)
222
+ else
223
+ raise ArgumentError, "Unknown service #{service}"
224
+ end
225
+ end
226
+
227
+ def parse_tracking_response(document)
228
+ response = {}
229
+ response[:tracking_numbers] = {}
230
+
231
+ tracking_numbers = REXML::XPath.match(document, "//FulfillmentShipmentPackage/member/TrackingNumber")
232
+ if tracking_numbers.present?
233
+ order_id = REXML::XPath.first(document, "//FulfillmentOrder/SellerFulfillmentOrderId").text.strip
234
+ response[:tracking_numbers][order_id] = tracking_numbers.map{ |t| t.text.strip }
235
+ end
236
+
237
+ response[:response_status] = SUCCESS
238
+ response
239
+ end
240
+
241
+ def parse_fulfillment_response(op, document)
242
+ { :response_status => SUCCESS, :response_comment => MESSAGES[op][SUCCESS] }
243
+ end
244
+
245
+ def parse_inventory_response(document)
246
+ response = {}
247
+ response[:stock_levels] = {}
248
+
249
+ document.each_element('//InventorySupplyList/member') do |node|
250
+ params = node.elements.to_a.each_with_object({}) { |elem, hash| hash[elem.name] = elem.text }
251
+
252
+ response[:stock_levels][params['SellerSKU']] = params['InStockSupplyQuantity'].to_i
253
+ end
254
+
255
+ next_token = REXML::XPath.first(document, '//NextToken')
256
+ response[:next_token] = next_token ? next_token.text : nil
257
+
258
+ response[:response_status] = SUCCESS
259
+ response
260
+ end
261
+
262
+ def parse_error(http_response)
263
+ response = {}
264
+ response[:http_code] = http_response.code
265
+ response[:http_message] = http_response.message
266
+
267
+ document = REXML::Document.new(http_response.body)
268
+
269
+ node = REXML::XPath.first(document, '//Error')
270
+ error_code = REXML::XPath.first(node, '//Code')
271
+ error_message = REXML::XPath.first(node, '//Message')
272
+
273
+ response[:status] = FAILURE
274
+ response[:faultcode] = error_code ? error_code.text : ""
275
+ response[:faultstring] = error_message ? error_message.text : ""
276
+ response[:response_message] = error_message ? error_message.text : ""
277
+ response[:response_comment] = "#{response[:faultcode]}: #{response[:faultstring]}"
278
+ response
279
+ rescue REXML::ParseException => e
280
+ rescue NoMethodError => e
281
+ response[:http_body] = http_response.body
282
+ response[:response_status] = FAILURE
283
+ response[:response_comment] = "#{response[:http_code]}: #{response[:http_message]}"
284
+ response
285
+ end
286
+
287
+ def sign(http_verb, uri, options)
288
+ string_to_sign = "#{http_verb.to_s.upcase}\n"
289
+ string_to_sign += "#{uri.host}\n"
290
+ string_to_sign += uri.path.length <= 0 ? "/\n" : "#{uri.path}\n"
291
+ string_to_sign += build_query(options)
292
+
293
+ # remove trailing newline created by encode64
294
+ escape(Base64.encode64(OpenSSL::HMAC.digest(SIGNATURE_METHOD, @options[:password], string_to_sign)).chomp)
295
+ end
296
+
297
+ def amazon_request?(signed_string, expected_signature)
298
+ calculated_signature = escape(Base64.encode64(OpenSSL::HMAC.digest(SIGNATURE_METHOD, @options[:password], signed_string)).chomp)
299
+ calculated_signature == expected_signature
300
+ end
301
+
302
+ def registration_url(options)
303
+ opts = {
304
+ "returnPathAndParameters" => options["returnPathAndParameters"],
305
+ "id" => @options[:app_id],
306
+ "AWSAccessKeyId" => @options[:login],
307
+ "SignatureMethod" => "Hmac#{SIGNATURE_METHOD}",
308
+ "SignatureVersion" => SIGNATURE_VERSION
309
+ }
310
+ signature = sign(:get, REGISTRATION_URI, opts)
311
+ "#{REGISTRATION_URI.to_s}?#{build_query(opts)}&Signature=#{signature}"
312
+ end
313
+
314
+ def md5_content(content)
315
+ Base64.encode64(OpenSSL::Digest::Digest.new('md5', content).digest).chomp
316
+ end
317
+
318
+ def build_query(query_params)
319
+ query_params.sort.map{ |key, value| [escape(key.to_s), escape(value.to_s)].join('=') }.join('&')
320
+ end
321
+
322
+ def build_headers(querystr)
323
+ {
324
+ 'User-Agent' => APPLICATION_IDENTIFIER,
325
+ 'Content-MD5' => md5_content(querystr),
326
+ 'Content-Type' => 'application/x-www-form-urlencoded'
327
+ }
328
+ end
329
+
330
+ def build_basic_api_query(options)
331
+ opts = Hash[options.map{ |k,v| [k.to_s, v.to_s] }]
332
+ opts["AWSAccessKeyId"] = @options[:login] unless opts["AWSAccessKey"]
333
+ opts["Timestamp"] = Time.now.utc.iso8601 unless opts["Timestamp"]
334
+ opts["Version"] = VERSION unless opts["Version"]
335
+ opts["SignatureMethod"] = "Hmac#{SIGNATURE_METHOD}" unless opts["SignatureMethod"]
336
+ opts["SignatureVersion"] = SIGNATURE_VERSION unless opts["SignatureVersion"]
337
+ opts["SellerId"] = @seller_id unless opts["SellerId"] || !@seller_id
338
+ opts
339
+ end
340
+
341
+ def build_fulfillment_request(order_id, shipping_address, line_items, options)
342
+ params = {
343
+ :Action => OPERATIONS[:outbound][:create],
344
+ :SellerFulfillmentOrderId => order_id.to_s,
345
+ :DisplayableOrderId => order_id.to_s,
346
+ :DisplayableOrderDateTime => options[:order_date].utc.iso8601,
347
+ :ShippingSpeedCategory => options[:shipping_method]
348
+ }
349
+ params[:DisplayableOrderComment] = options[:comment] if options[:comment]
350
+
351
+ request = build_basic_api_query(params.merge(options))
352
+ request = request.merge build_address(shipping_address)
353
+ request = request.merge build_items(line_items)
354
+
355
+ request
356
+ end
357
+
358
+ def build_get_current_fulfillment_orders_request(options = {})
359
+ start_time = options.delete(:start_time) || 1.day.ago.utc
360
+ params = {
361
+ :Action => OPERATIONS[:outbound][:list],
362
+ :QueryStartDateTime => start_time.strftime("%Y-%m-%dT%H:%M:%SZ")
363
+ }
364
+
365
+ build_basic_api_query(params.merge(options))
366
+ end
367
+
368
+ def build_inventory_list_request(options = {})
369
+ response_group = options.delete(:response_group) || "Basic"
370
+ params = {
371
+ :Action => OPERATIONS[:inventory][:list],
372
+ :ResponseGroup => response_group
373
+ }
374
+ if skus = options.delete(:skus)
375
+ skus.each_with_index do |sku, index|
376
+ params[LOOKUPS[:list_inventory][:sku] % (index + 1)] = sku
377
+ end
378
+ else
379
+ start_time = options.delete(:start_time) || 1.day.ago
380
+ params[:QueryStartDateTime] = start_time.utc.iso8601
381
+ end
382
+
383
+ build_basic_api_query(params.merge(options))
384
+ end
385
+
386
+ def build_next_inventory_list_request(token)
387
+ params = {
388
+ :NextToken => token,
389
+ :Action => OPERATIONS[:inventory][:list_next]
390
+ }
391
+
392
+ build_basic_api_query(params)
393
+ end
394
+
395
+ def build_tracking_request(order_id, options)
396
+ params = {:Action => OPERATIONS[:outbound][:tracking], :SellerFulfillmentOrderId => order_id}
397
+
398
+ build_basic_api_query(params.merge(options))
399
+ end
400
+
401
+ def build_address(address)
402
+ requires!(address, :name, :address1, :city, :state, :country, :zip)
403
+ ary = address.map{ |key, value| [LOOKUPS[:destination_address][key], value] if LOOKUPS[:destination_address].include?(key) && value.present? }
404
+ Hash[ary.compact]
405
+ end
406
+
407
+ def build_items(line_items)
408
+ lookup = LOOKUPS[:line_items]
409
+ counter = 0
410
+ line_items.reduce({}) do |items, line_item|
411
+ counter += 1
412
+ lookup.each do |key, value|
413
+ entry = value % counter
414
+ case key
415
+ when :sku
416
+ items[entry] = line_item[:sku] || "SKU-#{counter}"
417
+ when :order_id
418
+ items[entry] = line_item[:sku] || "FULFILLMENT-ITEM-ID-#{counter}"
419
+ when :quantity
420
+ items[entry] = line_item[:quantity] || 1
421
+ else
422
+ items[entry] = line_item[key] if line_item.include? key
423
+ end
424
+ end
425
+ items
426
+ end
427
+ end
428
+
429
+ def build_status_request
430
+ build_basic_api_query({ :Action => OPERATIONS[:outbound][:status] })
431
+ end
432
+
433
+ def escape(str)
434
+ CGI.escape(str.to_s).gsub('+', '%20')
435
+ end
436
+ end
437
+ end
438
+ end