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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.circleci/config.yml +41 -0
- data/.gem_release.yml +5 -0
- data/.github/stale.yml +17 -0
- data/.github_changelog_generator +2 -0
- data/.gitignore +20 -0
- data/.rspec +2 -0
- data/.rubocop.yml +13 -0
- data/.rubocop_todo.yml +40 -0
- data/CHANGELOG.md +36 -0
- data/Gemfile +33 -0
- data/LICENSE +26 -0
- data/README.md +208 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/spree/backend/solidus_shipstation.js +2 -0
- data/app/assets/javascripts/spree/frontend/solidus_shipstation.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_shipstation.css +4 -0
- data/app/assets/stylesheets/spree/frontend/solidus_shipstation.css +4 -0
- data/app/controllers/spree/shipstation_controller.rb +45 -0
- data/app/decorators/models/solidus_shipstation/spree/shipment_decorator.rb +33 -0
- data/app/helpers/solidus_shipstation/export_helper.rb +32 -0
- data/app/jobs/solidus_shipstation/api/schedule_shipment_syncs_job.rb +19 -0
- data/app/jobs/solidus_shipstation/api/sync_shipments_job.rb +41 -0
- data/app/queries/solidus_shipstation/shipment/between_query.rb +14 -0
- data/app/queries/solidus_shipstation/shipment/exportable_query.rb +24 -0
- data/app/queries/solidus_shipstation/shipment/pending_api_sync_query.rb +63 -0
- data/app/views/spree/shipstation/export.xml.builder +58 -0
- data/bin/console +17 -0
- data/bin/rails +7 -0
- data/bin/rails-engine +13 -0
- data/bin/rails-sandbox +16 -0
- data/bin/rake +7 -0
- data/bin/sandbox +86 -0
- data/bin/setup +8 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20210220093010_add_shipstation_api_sync_fields.rb +9 -0
- data/lib/generators/solidus_shipstation/install/install_generator.rb +27 -0
- data/lib/generators/solidus_shipstation/install/templates/initializer.rb +62 -0
- data/lib/solidus_shipstation.rb +16 -0
- data/lib/solidus_shipstation/api/batch_syncer.rb +70 -0
- data/lib/solidus_shipstation/api/client.rb +38 -0
- data/lib/solidus_shipstation/api/rate_limited_error.rb +23 -0
- data/lib/solidus_shipstation/api/request_error.rb +33 -0
- data/lib/solidus_shipstation/api/request_runner.rb +50 -0
- data/lib/solidus_shipstation/api/shipment_serializer.rb +84 -0
- data/lib/solidus_shipstation/api/threshold_verifier.rb +28 -0
- data/lib/solidus_shipstation/configuration.rb +44 -0
- data/lib/solidus_shipstation/engine.rb +19 -0
- data/lib/solidus_shipstation/errors.rb +23 -0
- data/lib/solidus_shipstation/shipment_notice.rb +58 -0
- data/lib/solidus_shipstation/testing_support/factories.rb +4 -0
- data/lib/solidus_shipstation/version.rb +5 -0
- data/solidus_shipstation.gemspec +40 -0
- data/spec/controllers/spree/shipstation_controller_spec.rb +103 -0
- data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
- data/spec/jobs/solidus_shipstation/api/schedule_shipment_syncs_job_spec.rb +32 -0
- data/spec/jobs/solidus_shipstation/api/sync_shipments_job_spec.rb +102 -0
- data/spec/lib/solidus_shipstation/api/batch_syncer_spec.rb +229 -0
- data/spec/lib/solidus_shipstation/api/client_spec.rb +120 -0
- data/spec/lib/solidus_shipstation/api/rate_limited_error_spec.rb +21 -0
- data/spec/lib/solidus_shipstation/api/request_error_spec.rb +20 -0
- data/spec/lib/solidus_shipstation/api/request_runner_spec.rb +64 -0
- data/spec/lib/solidus_shipstation/api/shipment_serializer_spec.rb +12 -0
- data/spec/lib/solidus_shipstation/api/threshold_verifier_spec.rb +61 -0
- data/spec/lib/solidus_shipstation/shipment_notice_spec.rb +111 -0
- data/spec/lib/solidus_shipstation_spec.rb +9 -0
- data/spec/models/spree/shipment_spec.rb +49 -0
- data/spec/queries/solidus_shipstation/shipment/between_query_spec.rb +53 -0
- data/spec/queries/solidus_shipstation/shipment/exportable_query_spec.rb +53 -0
- data/spec/queries/solidus_shipstation/shipment/pending_api_sync_query_spec.rb +37 -0
- data/spec/spec_helper.rb +31 -0
- data/spec/support/configuration_helper.rb +13 -0
- data/spec/support/controllers.rb +1 -0
- data/spec/support/webmock.rb +3 -0
- data/spec/support/xsd.rb +5 -0
- metadata +248 -0
data/bin/setup
ADDED
data/config/routes.rb
ADDED
@@ -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
|