solidus_shipstation 1.0.0

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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.circleci/config.yml +41 -0
  4. data/.gem_release.yml +5 -0
  5. data/.github/stale.yml +17 -0
  6. data/.github_changelog_generator +2 -0
  7. data/.gitignore +20 -0
  8. data/.rspec +2 -0
  9. data/.rubocop.yml +13 -0
  10. data/.rubocop_todo.yml +40 -0
  11. data/CHANGELOG.md +36 -0
  12. data/Gemfile +33 -0
  13. data/LICENSE +26 -0
  14. data/README.md +208 -0
  15. data/Rakefile +6 -0
  16. data/app/assets/javascripts/spree/backend/solidus_shipstation.js +2 -0
  17. data/app/assets/javascripts/spree/frontend/solidus_shipstation.js +2 -0
  18. data/app/assets/stylesheets/spree/backend/solidus_shipstation.css +4 -0
  19. data/app/assets/stylesheets/spree/frontend/solidus_shipstation.css +4 -0
  20. data/app/controllers/spree/shipstation_controller.rb +45 -0
  21. data/app/decorators/models/solidus_shipstation/spree/shipment_decorator.rb +33 -0
  22. data/app/helpers/solidus_shipstation/export_helper.rb +32 -0
  23. data/app/jobs/solidus_shipstation/api/schedule_shipment_syncs_job.rb +19 -0
  24. data/app/jobs/solidus_shipstation/api/sync_shipments_job.rb +41 -0
  25. data/app/queries/solidus_shipstation/shipment/between_query.rb +14 -0
  26. data/app/queries/solidus_shipstation/shipment/exportable_query.rb +24 -0
  27. data/app/queries/solidus_shipstation/shipment/pending_api_sync_query.rb +63 -0
  28. data/app/views/spree/shipstation/export.xml.builder +58 -0
  29. data/bin/console +17 -0
  30. data/bin/rails +7 -0
  31. data/bin/rails-engine +13 -0
  32. data/bin/rails-sandbox +16 -0
  33. data/bin/rake +7 -0
  34. data/bin/sandbox +86 -0
  35. data/bin/setup +8 -0
  36. data/config/locales/en.yml +5 -0
  37. data/config/routes.rb +6 -0
  38. data/db/migrate/20210220093010_add_shipstation_api_sync_fields.rb +9 -0
  39. data/lib/generators/solidus_shipstation/install/install_generator.rb +27 -0
  40. data/lib/generators/solidus_shipstation/install/templates/initializer.rb +62 -0
  41. data/lib/solidus_shipstation.rb +16 -0
  42. data/lib/solidus_shipstation/api/batch_syncer.rb +70 -0
  43. data/lib/solidus_shipstation/api/client.rb +38 -0
  44. data/lib/solidus_shipstation/api/rate_limited_error.rb +23 -0
  45. data/lib/solidus_shipstation/api/request_error.rb +33 -0
  46. data/lib/solidus_shipstation/api/request_runner.rb +50 -0
  47. data/lib/solidus_shipstation/api/shipment_serializer.rb +84 -0
  48. data/lib/solidus_shipstation/api/threshold_verifier.rb +28 -0
  49. data/lib/solidus_shipstation/configuration.rb +44 -0
  50. data/lib/solidus_shipstation/engine.rb +19 -0
  51. data/lib/solidus_shipstation/errors.rb +23 -0
  52. data/lib/solidus_shipstation/shipment_notice.rb +58 -0
  53. data/lib/solidus_shipstation/testing_support/factories.rb +4 -0
  54. data/lib/solidus_shipstation/version.rb +5 -0
  55. data/solidus_shipstation.gemspec +40 -0
  56. data/spec/controllers/spree/shipstation_controller_spec.rb +103 -0
  57. data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
  58. data/spec/jobs/solidus_shipstation/api/schedule_shipment_syncs_job_spec.rb +32 -0
  59. data/spec/jobs/solidus_shipstation/api/sync_shipments_job_spec.rb +102 -0
  60. data/spec/lib/solidus_shipstation/api/batch_syncer_spec.rb +229 -0
  61. data/spec/lib/solidus_shipstation/api/client_spec.rb +120 -0
  62. data/spec/lib/solidus_shipstation/api/rate_limited_error_spec.rb +21 -0
  63. data/spec/lib/solidus_shipstation/api/request_error_spec.rb +20 -0
  64. data/spec/lib/solidus_shipstation/api/request_runner_spec.rb +64 -0
  65. data/spec/lib/solidus_shipstation/api/shipment_serializer_spec.rb +12 -0
  66. data/spec/lib/solidus_shipstation/api/threshold_verifier_spec.rb +61 -0
  67. data/spec/lib/solidus_shipstation/shipment_notice_spec.rb +111 -0
  68. data/spec/lib/solidus_shipstation_spec.rb +9 -0
  69. data/spec/models/spree/shipment_spec.rb +49 -0
  70. data/spec/queries/solidus_shipstation/shipment/between_query_spec.rb +53 -0
  71. data/spec/queries/solidus_shipstation/shipment/exportable_query_spec.rb +53 -0
  72. data/spec/queries/solidus_shipstation/shipment/pending_api_sync_query_spec.rb +37 -0
  73. data/spec/spec_helper.rb +31 -0
  74. data/spec/support/configuration_helper.rb +13 -0
  75. data/spec/support/controllers.rb +1 -0
  76. data/spec/support/webmock.rb +3 -0
  77. data/spec/support/xsd.rb +5 -0
  78. metadata +248 -0
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ gem install bundler --conservative
7
+ bundle update
8
+ bin/rake clobber
@@ -0,0 +1,5 @@
1
+ ---
2
+ en:
3
+ shipment_not_found: Shipment %{number} was not found
4
+ import_tracking_error: "Tracking number cannot be imported. Error: %{error}"
5
+ capture_payment_error: "Error in capture of payment for order %{number}. Error: %{error}"
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Spree::Core::Engine.routes.draw do
4
+ get '/shipstation', to: 'shipstation#export'
5
+ post '/shipstation', to: 'shipstation#shipnotify'
6
+ end
@@ -0,0 +1,9 @@
1
+ # NOTE: This migration is only required if you use the API integration strategy.
2
+ # If you're using the XML file instead, you can safely skip these columns.
3
+
4
+ class AddShipstationApiSyncFields < ActiveRecord::Migration[5.2]
5
+ def change
6
+ add_column :spree_shipments, :shipstation_synced_at, :datetime
7
+ add_column :spree_shipments, :shipstation_order_id, :string
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ class_option :auto_run_migrations, type: :boolean, default: false
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ def copy_initializer
10
+ template 'initializer.rb', 'config/initializers/solidus_shipstation.rb'
11
+ end
12
+
13
+ def add_migrations
14
+ run 'bin/rails railties:install:migrations FROM=solidus_shipstation'
15
+ end
16
+
17
+ def run_migrations
18
+ run_migrations = options[:auto_run_migrations] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]'))
19
+ if run_migrations
20
+ run 'bin/rails db:migrate'
21
+ else
22
+ puts 'Skipping bin/rails db:migrate, don\'t forget to run it!' # rubocop:disable Rails/Output
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ SolidusShipstation.configure do |config|
4
+ # Choose between Grams, Ounces or Pounds.
5
+ config.weight_units = "Grams"
6
+
7
+ # Capture payment when ShipStation notifies a shipping label creation.
8
+ # Set this to `true` and `Spree::Config.require_payment_to_ship` to `false` if you
9
+ # want to charge your customers at the time of shipment.
10
+ config.capture_at_notification = false
11
+
12
+ # ShipStation expects the endpoint to be protected by HTTP Basic Auth.
13
+ # Set the username and password you desire for ShipStation to use.
14
+ config.username = "smoking_jay_cutler"
15
+ config.password = "my-awesome-password"
16
+
17
+ ####### XML integration
18
+ # Only uncomment these lines if you're going to use the XML integration.
19
+
20
+ # Export canceled shipments to ShipStation
21
+ # Set this to `true` if you want canceled shipments included in the endpoint.
22
+ # config.export_canceled_shipments = false
23
+
24
+ ####### API integration
25
+ # Only uncomment these lines if you're going to use the API integration.
26
+
27
+ # Override the shipment serializer used for API sync. This can be any object
28
+ # that responds to `#call`. At the very least, you'll need to uncomment the
29
+ # following lines and customize your store ID.
30
+ # config.api_shipment_serializer = proc do |shipment|
31
+ # SolidusShipstation::Api::ShipmentSerializer.new(store_id: '12345678').call(shipment)
32
+ # end
33
+
34
+ # Override the logic used to match a ShipStation order to a shipment from a
35
+ # given collection. This can be useful when you override the default serializer
36
+ # and change the logic used to generate the order number.
37
+ # config.api_shipment_matcher = proc do |shipstation_order, shipments|
38
+ # shipments.find { |shipment| shipment.number == shipstation_order['orderNumber'] }
39
+ # end
40
+
41
+ # API key and secret for accessing the ShipStation API.
42
+ # config.api_key = "api-key"
43
+ # config.api_secret = "api-secret"
44
+
45
+ # Number of shipments to import into ShipStation at once.
46
+ # If unsure, leave this set to 100, which is the maximum
47
+ # number of shipments that can be imported at once.
48
+ # config.api_batch_size = 100
49
+
50
+ # Period of time after which the integration will "drop" shipments and stop
51
+ # trying to create/update them. This prevents the API from retrying indefinitely
52
+ # in case an error prevents some shipments from being created/updated.
53
+ # config.api_sync_threshold = 7.days
54
+
55
+ # Error handler used by the API integration for certain non-critical errors (e.g.
56
+ # a failure when serializing a shipment from a batch). This should be a proc that
57
+ # accepts an exception and a context hash. Popular options for error handling are
58
+ # logging or sending the error to an error tracking tool such as Sentry.
59
+ # config.error_handler = -> (error, context = {}) {
60
+ # Sentry.capture_exception(error, extra: context)
61
+ # }
62
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+
5
+ require 'solidus_shipstation/api/batch_syncer'
6
+ require 'solidus_shipstation/api/request_runner'
7
+ require 'solidus_shipstation/api/client'
8
+ require 'solidus_shipstation/api/request_error'
9
+ require 'solidus_shipstation/api/rate_limited_error'
10
+ require 'solidus_shipstation/api/shipment_serializer'
11
+ require 'solidus_shipstation/api/threshold_verifier'
12
+ require 'solidus_shipstation/configuration'
13
+ require 'solidus_shipstation/errors'
14
+ require 'solidus_shipstation/shipment_notice'
15
+ require 'solidus_shipstation/version'
16
+ require 'solidus_shipstation/engine'
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class BatchSyncer
6
+ class << self
7
+ def from_config
8
+ new(
9
+ client: SolidusShipstation::Api::Client.from_config,
10
+ shipment_matcher: SolidusShipstation.config.api_shipment_matcher,
11
+ )
12
+ end
13
+ end
14
+
15
+ attr_reader :client, :shipment_matcher
16
+
17
+ def initialize(client:, shipment_matcher:)
18
+ @client = client
19
+ @shipment_matcher = shipment_matcher
20
+ end
21
+
22
+ def call(shipments)
23
+ begin
24
+ response = client.bulk_create_orders(shipments)
25
+ rescue RateLimitedError => e
26
+ ::Spree::Event.fire(
27
+ 'solidus_shipstation.api.rate_limited',
28
+ shipments: shipments,
29
+ error: e,
30
+ )
31
+
32
+ raise e
33
+ rescue RequestError => e
34
+ ::Spree::Event.fire(
35
+ 'solidus_shipstation.api.sync_errored',
36
+ shipments: shipments,
37
+ error: e,
38
+ )
39
+
40
+ raise e
41
+ end
42
+
43
+ response['results'].each do |shipstation_order|
44
+ shipment = shipment_matcher.call(shipstation_order, shipments)
45
+
46
+ unless shipstation_order['success']
47
+ ::Spree::Event.fire(
48
+ 'solidus_shipstation.api.sync_failed',
49
+ shipment: shipment,
50
+ payload: shipstation_order,
51
+ )
52
+
53
+ next
54
+ end
55
+
56
+ shipment.update_columns(
57
+ shipstation_synced_at: Time.zone.now,
58
+ shipstation_order_id: shipstation_order['orderId'],
59
+ )
60
+
61
+ ::Spree::Event.fire(
62
+ 'solidus_shipstation.api.sync_completed',
63
+ shipment: shipment,
64
+ payload: shipstation_order,
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class Client
6
+ class << self
7
+ def from_config
8
+ new(
9
+ request_runner: RequestRunner.from_config,
10
+ error_handler: SolidusShipstation.config.error_handler,
11
+ shipment_serializer: SolidusShipstation.config.api_shipment_serializer,
12
+ )
13
+ end
14
+ end
15
+
16
+ attr_reader :request_runner, :error_handler, :shipment_serializer
17
+
18
+ def initialize(request_runner:, error_handler:, shipment_serializer:)
19
+ @request_runner = request_runner
20
+ @error_handler = error_handler
21
+ @shipment_serializer = shipment_serializer
22
+ end
23
+
24
+ def bulk_create_orders(shipments)
25
+ params = shipments.map do |shipment|
26
+ shipment_serializer.call(shipment)
27
+ rescue StandardError => e
28
+ error_handler.call(e, shipment: shipment)
29
+ nil
30
+ end.compact
31
+
32
+ return if params.empty?
33
+
34
+ request_runner.call(:post, '/orders/createorders', params)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class RateLimitedError < RequestError
6
+ attr_reader :retry_in
7
+
8
+ class << self
9
+ def options_from_response(response)
10
+ super.merge(
11
+ retry_in: response.headers['X-Rate-Limit-Reset'].to_i.seconds,
12
+ )
13
+ end
14
+ end
15
+
16
+ def initialize(retry_in:, **options)
17
+ super(**options)
18
+
19
+ @retry_in = retry_in
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class RequestError < RuntimeError
6
+ attr_reader :response_code, :response_body, :response_headers
7
+
8
+ class << self
9
+ def from_response(response)
10
+ new(**options_from_response(response))
11
+ end
12
+
13
+ private
14
+
15
+ def options_from_response(response)
16
+ {
17
+ response_code: response.code,
18
+ response_headers: response.headers,
19
+ response_body: response.body,
20
+ }
21
+ end
22
+ end
23
+
24
+ def initialize(response_code:, response_body:, response_headers:)
25
+ @response_code = response_code
26
+ @response_body = response_body
27
+ @response_headers = response_headers
28
+
29
+ super(response_body)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class RequestRunner
6
+ API_BASE = 'https://ssapi.shipstation.com'
7
+
8
+ attr_reader :username, :password
9
+
10
+ class << self
11
+ def from_config
12
+ new(
13
+ username: SolidusShipstation.config.api_key,
14
+ password: SolidusShipstation.config.api_secret,
15
+ )
16
+ end
17
+ end
18
+
19
+ def initialize(username:, password:)
20
+ @username = username
21
+ @password = password
22
+ end
23
+
24
+ def call(method, path, params = {})
25
+ response = HTTParty.send(
26
+ method,
27
+ URI.join(API_BASE, path),
28
+ body: params.to_json,
29
+ basic_auth: {
30
+ username: @username,
31
+ password: @password,
32
+ },
33
+ headers: {
34
+ 'Content-Type' => 'application/json',
35
+ 'Accept' => 'application/json',
36
+ },
37
+ )
38
+
39
+ case response.code.to_s
40
+ when /2\d{2}/
41
+ response.parsed_response
42
+ when '429'
43
+ raise RateLimitedError.from_response(response)
44
+ else
45
+ raise RequestError.from_response(response)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusShipstation
4
+ module Api
5
+ class ShipmentSerializer
6
+ attr_reader :store_id
7
+
8
+ def initialize(store_id:)
9
+ @store_id = store_id
10
+ end
11
+
12
+ def call(shipment)
13
+ order = shipment.order
14
+
15
+ state = case shipment.state
16
+ when 'ready'
17
+ 'awaiting_shipment'
18
+ when 'shipped'
19
+ 'shipped'
20
+ when 'pending'
21
+ if ::Spree::Config.require_payment_to_ship && !shipment.order.paid?
22
+ 'awaiting_payment'
23
+ else
24
+ 'on_hold'
25
+ end
26
+ when 'canceled'
27
+ 'cancelled'
28
+ end
29
+
30
+ {
31
+ orderNumber: shipment.number,
32
+ orderKey: shipment.number,
33
+ orderDate: order.completed_at.iso8601,
34
+ paymentDate: order.payments.find(&:valid?)&.created_at&.iso8601,
35
+ orderStatus: state,
36
+ customerId: order.user&.id,
37
+ customerUsername: order.email,
38
+ customerEmail: order.email,
39
+ billTo: serialize_address(order.bill_address),
40
+ shipTo: serialize_address(order.ship_address),
41
+ items: shipment.line_items.map(&method(:serialize_line_item)),
42
+ shippingAmount: shipment.cost,
43
+ paymentMethod: 'Credit Card',
44
+ advancedOptions: {
45
+ storeId: store_id,
46
+ },
47
+ }
48
+ end
49
+
50
+ private
51
+
52
+ def serialize_address(address)
53
+ {
54
+ name: SolidusSupport.combined_first_and_last_name_in_address? ? address.name : address.full_name,
55
+ company: address.company,
56
+ street1: address.address1,
57
+ street2: address.address2,
58
+ city: address.city,
59
+ state: address.state&.abbr,
60
+ postalCode: address.zipcode,
61
+ country: address.country&.iso,
62
+ phone: address.phone,
63
+ }
64
+ end
65
+
66
+ def serialize_line_item(line_item)
67
+ {
68
+ lineItemKey: "LineItem/#{line_item.id}",
69
+ sku: line_item.sku,
70
+ name: line_item.variant.descriptive_name,
71
+ imageUrl: line_item.variant.images.first.try(:attachment).try(:url),
72
+ quantity: line_item.quantity,
73
+ unitPrice: line_item.price,
74
+ taxAmount: line_item.additional_tax_total,
75
+ adjustment: false,
76
+ weight: {
77
+ value: line_item.variant.weight.to_f,
78
+ units: SolidusShipstation.config.weight_units,
79
+ },
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end