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