solidus_shipstation 1.0.0

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