active_fulfillment 2.0.1 → 2.0.2

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