spree-shipstation 5.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/.github/stale.yml +17 -0
- data/.github/workflows/lint.yml +24 -0
- data/.github/workflows/security.yml +41 -0
- data/.github/workflows/test.yml +32 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/Appraisals +20 -0
- data/CHANGELOG.md +43 -0
- data/CLAUDE.md +95 -0
- data/Gemfile +14 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +69 -0
- data/app/assets/config/spree_shipstation_manifest.js +1 -0
- data/app/assets/images/integration_icons/shipstation-logo.webp +0 -0
- data/app/controllers/spree/shipstation_controller.rb +57 -0
- data/app/helpers/spree/shipstation/export_helper.rb +53 -0
- data/app/models/spree/integrations/shipstation.rb +61 -0
- data/app/models/spree/shipment_decorator.rb +28 -0
- data/app/presenters/spree/shipstation/export/item_presenter.rb +52 -0
- data/app/presenters/spree/shipstation/export/order_presenter.rb +91 -0
- data/app/presenters/spree/shipstation/export/weight.rb +45 -0
- data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +6 -0
- data/app/views/spree/shipstation/export.xml.builder +59 -0
- data/bin/rails +8 -0
- data/config/initializers/spree.rb +3 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +6 -0
- data/gemfiles/spree_5_2.gemfile +18 -0
- data/gemfiles/spree_5_3.gemfile +18 -0
- data/gemfiles/spree_5_4.gemfile +18 -0
- data/lib/spree/shipstation/engine.rb +32 -0
- data/lib/spree/shipstation/errors.rb +25 -0
- data/lib/spree/shipstation/factories.rb +5 -0
- data/lib/spree/shipstation/shipment_notice.rb +68 -0
- data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +9 -0
- data/lib/spree/shipstation/version.rb +12 -0
- data/lib/spree/shipstation.rb +11 -0
- data/lib/spree-shipstation.rb +8 -0
- data/spec/fixtures/shipstation_xml_schema.xsd +171 -0
- data/spree-shipstation.gemspec +36 -0
- metadata +140 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Integrations
|
|
5
|
+
class Shipstation < Spree::Integration
|
|
6
|
+
preference :username, :string
|
|
7
|
+
|
|
8
|
+
validates :preferred_username,
|
|
9
|
+
presence: true,
|
|
10
|
+
length: {minimum: 10, maximum: 30}
|
|
11
|
+
|
|
12
|
+
# Restrict to characters that are safe to send in an HTTP Basic Auth header:
|
|
13
|
+
# a-z, A-Z, 0-9, and . _ @ + -
|
|
14
|
+
validates :preferred_username,
|
|
15
|
+
format: {
|
|
16
|
+
with: /\A[a-zA-Z0-9._@+-]+\z/,
|
|
17
|
+
message: Spree.t("admin.integrations.shipstation.username_chars_error")
|
|
18
|
+
},
|
|
19
|
+
allow_blank: true
|
|
20
|
+
|
|
21
|
+
preference :password, :password
|
|
22
|
+
|
|
23
|
+
validates :preferred_password,
|
|
24
|
+
presence: true,
|
|
25
|
+
length: {minimum: 20, maximum: 60}
|
|
26
|
+
|
|
27
|
+
validates :preferred_password,
|
|
28
|
+
format: {
|
|
29
|
+
with: /[!@#$%^&*(),.?":{}|<>]/,
|
|
30
|
+
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_special_character")
|
|
31
|
+
},
|
|
32
|
+
allow_blank: true
|
|
33
|
+
|
|
34
|
+
validates :preferred_password,
|
|
35
|
+
format: {
|
|
36
|
+
with: /[A-Z]/,
|
|
37
|
+
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_uppercase_letter")
|
|
38
|
+
},
|
|
39
|
+
allow_blank: true
|
|
40
|
+
|
|
41
|
+
validates :preferred_password,
|
|
42
|
+
format: {
|
|
43
|
+
with: /\d/,
|
|
44
|
+
message: Spree.t("admin.integrations.shipstation.must_contain_at_least_one_number")
|
|
45
|
+
},
|
|
46
|
+
allow_blank: true
|
|
47
|
+
|
|
48
|
+
def self.integration_group
|
|
49
|
+
"Shipping"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.icon_path
|
|
53
|
+
"integration_icons/shipstation-logo.webp"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.integration_name
|
|
57
|
+
Spree.t("admin.integrations.shipstation.brand_name")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module ShipmentDecorator
|
|
5
|
+
def self.prepended(base)
|
|
6
|
+
base.scope :exportable, lambda {
|
|
7
|
+
joins(:order)
|
|
8
|
+
.merge(::Spree::Order.complete)
|
|
9
|
+
.where(state: "ready")
|
|
10
|
+
.order(:updated_at)
|
|
11
|
+
.includes(:order, inventory_units: {line_item: {variant: [:product, :images, {option_values: :option_type}]}})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
base.scope :between, lambda { |from, to|
|
|
15
|
+
return all if from.nil? && to.nil?
|
|
16
|
+
|
|
17
|
+
range = from..to
|
|
18
|
+
|
|
19
|
+
shipment_match = joins(:order).where(updated_at: range)
|
|
20
|
+
order_match = joins(:order).where(spree_orders: {updated_at: range})
|
|
21
|
+
|
|
22
|
+
shipment_match.or(order_match).distinct
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
::Spree::Shipment.prepend self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
module Export
|
|
6
|
+
# Shapes a shipment line item (a line item plus its inventory units) into the
|
|
7
|
+
# values ShipStation's export XML expects for an <Item>.
|
|
8
|
+
class ItemPresenter
|
|
9
|
+
attr_reader :line_item, :units
|
|
10
|
+
|
|
11
|
+
def initialize(line_item, units)
|
|
12
|
+
@line_item = line_item
|
|
13
|
+
@units = units
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def variant
|
|
17
|
+
@variant ||= line_item.variant
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def sku
|
|
21
|
+
variant.sku
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
[variant.product.name, variant.options_text].reject(&:blank?).join(" ")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The Spree::Image to advertise, or nil. URL generation stays in the view
|
|
29
|
+
# because it depends on Rails route/URL helpers.
|
|
30
|
+
def image
|
|
31
|
+
variant.images.first || variant.product.master.images.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def weight
|
|
35
|
+
Weight.from_variant(variant)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def quantity
|
|
39
|
+
units.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def unit_price
|
|
43
|
+
line_item.price
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def option_values
|
|
47
|
+
variant.option_values
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
module Export
|
|
6
|
+
# Shapes a single Spree::Shipment into the values ShipStation's export XML
|
|
7
|
+
# expects, keeping data-massaging logic out of the Builder template.
|
|
8
|
+
#
|
|
9
|
+
# Note: ShipStation models one "Order" per shipment, so most order-level
|
|
10
|
+
# fields are derived from `shipment.order` while identifiers come from the
|
|
11
|
+
# shipment itself (mirroring the <OrderNumber> = shipment.number contract).
|
|
12
|
+
class OrderPresenter
|
|
13
|
+
attr_reader :shipment
|
|
14
|
+
|
|
15
|
+
def initialize(shipment)
|
|
16
|
+
@shipment = shipment
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def order
|
|
20
|
+
shipment.order
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def order_id
|
|
24
|
+
shipment.id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def order_number
|
|
28
|
+
shipment.number
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def order_status
|
|
32
|
+
shipment.state
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def order_date
|
|
36
|
+
format_date(order.completed_at)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def last_modified
|
|
40
|
+
format_date([order.completed_at, shipment.updated_at].compact.max)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def shipping_method_name
|
|
44
|
+
shipment.shipping_method&.name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def order_total
|
|
48
|
+
order.total
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tax_total
|
|
52
|
+
order.tax_total
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ship_total
|
|
56
|
+
order.ship_total
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def custom_field_1
|
|
60
|
+
order.number
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def customer_code
|
|
64
|
+
order.email&.slice(0, 50)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bill_address
|
|
68
|
+
order.bill_address
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ship_address
|
|
72
|
+
order.ship_address
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def items
|
|
76
|
+
shipment.inventory_units.group_by(&:line_item).filter_map do |line_item, units|
|
|
77
|
+
next unless line_item.variant
|
|
78
|
+
|
|
79
|
+
ItemPresenter.new(line_item, units)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def format_date(time)
|
|
86
|
+
time&.strftime(ExportHelper::DATE_FORMAT)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
module Export
|
|
6
|
+
# Value object converting a Spree::Variant's weight into the
|
|
7
|
+
# (value, unit-label) pair ShipStation's export XML expects.
|
|
8
|
+
#
|
|
9
|
+
# ShipStation accepts Pounds, Ounces, and Grams; any unrecognised Spree
|
|
10
|
+
# weight unit (including a missing weight) falls back to Grams.
|
|
11
|
+
class Weight
|
|
12
|
+
attr_reader :value, :units
|
|
13
|
+
|
|
14
|
+
def initialize(value:, units:)
|
|
15
|
+
@value = value
|
|
16
|
+
@units = units
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_variant(variant)
|
|
20
|
+
amount = (variant.weight || 0.0).to_f
|
|
21
|
+
|
|
22
|
+
case variant.weight_unit
|
|
23
|
+
when "lb"
|
|
24
|
+
new(value: amount, units: "Pounds")
|
|
25
|
+
when "oz"
|
|
26
|
+
new(value: amount, units: "Ounces")
|
|
27
|
+
when "kg"
|
|
28
|
+
new(value: amount * 1000, units: "Grams")
|
|
29
|
+
else
|
|
30
|
+
new(value: amount, units: "Grams")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ==(other)
|
|
35
|
+
other.is_a?(self.class) && value == other.value && units == other.units
|
|
36
|
+
end
|
|
37
|
+
alias_method :eql?, :==
|
|
38
|
+
|
|
39
|
+
def hash
|
|
40
|
+
[value, units].hash
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div class="card mb-4">
|
|
2
|
+
<div class="card-body">
|
|
3
|
+
<%= preference_field(@integration, form, 'username', i18n_scope: 'admin.integrations.shipstation') %>
|
|
4
|
+
<%= preference_field(@integration, form, 'password', i18n_scope: 'admin.integrations.shipstation') %>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
xml.instruct!
|
|
2
|
+
|
|
3
|
+
xml.Orders(pages: @pagy.pages) do
|
|
4
|
+
@shipments.each do |shipment|
|
|
5
|
+
order = Spree::Shipstation::Export::OrderPresenter.new(shipment)
|
|
6
|
+
|
|
7
|
+
xml.Order do
|
|
8
|
+
xml.OrderID order.order_id
|
|
9
|
+
xml.OrderNumber order.order_number
|
|
10
|
+
|
|
11
|
+
xml.OrderDate order.order_date
|
|
12
|
+
xml.OrderStatus order.order_status
|
|
13
|
+
xml.LastModified order.last_modified
|
|
14
|
+
|
|
15
|
+
xml.ShippingMethod order.shipping_method_name
|
|
16
|
+
xml.OrderTotal order.order_total
|
|
17
|
+
xml.TaxAmount order.tax_total
|
|
18
|
+
xml.ShippingAmount order.ship_total
|
|
19
|
+
xml.CustomField1 order.custom_field_1
|
|
20
|
+
|
|
21
|
+
xml.Customer do
|
|
22
|
+
xml.CustomerCode order.customer_code
|
|
23
|
+
Spree::Shipstation::ExportHelper.bill_address(xml, order.bill_address)
|
|
24
|
+
Spree::Shipstation::ExportHelper.ship_address(xml, order.ship_address)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
xml.Items do
|
|
28
|
+
order.items.each do |item|
|
|
29
|
+
weight = item.weight
|
|
30
|
+
image = item.image
|
|
31
|
+
|
|
32
|
+
xml.Item do
|
|
33
|
+
xml.SKU item.sku
|
|
34
|
+
xml.Name item.name
|
|
35
|
+
|
|
36
|
+
image_url = image && url_for(image.attachment)
|
|
37
|
+
xml.ImageUrl image_url if image_url
|
|
38
|
+
|
|
39
|
+
xml.Weight weight.value
|
|
40
|
+
xml.WeightUnits weight.units
|
|
41
|
+
xml.Quantity item.quantity
|
|
42
|
+
xml.UnitPrice item.unit_price
|
|
43
|
+
|
|
44
|
+
if item.option_values.present?
|
|
45
|
+
xml.Options do
|
|
46
|
+
item.option_values.each do |value|
|
|
47
|
+
xml.Option do
|
|
48
|
+
xml.Name value.option_type.presentation
|
|
49
|
+
xml.Value value.name
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/bin/rails
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# This command will automatically be run when you run "rails" from the root of your extension
|
|
3
|
+
|
|
4
|
+
ENGINE_ROOT = File.expand_path('..', __dir__)
|
|
5
|
+
ENGINE_PATH = File.expand_path('../lib/spree/shipstation/engine', __dir__)
|
|
6
|
+
|
|
7
|
+
require 'rails/all'
|
|
8
|
+
require 'rails/engine/commands'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
en:
|
|
3
|
+
spree:
|
|
4
|
+
admin:
|
|
5
|
+
integrations:
|
|
6
|
+
shipstation:
|
|
7
|
+
must_contain_at_least_one_special_character: must contain at least one special character
|
|
8
|
+
must_contain_at_least_one_uppercase_letter: must contain at least one uppercase letter
|
|
9
|
+
must_contain_at_least_one_number: must contain at least one number
|
|
10
|
+
username_chars_error: can only contain letters, numbers, and . _ @ + -
|
|
11
|
+
username: Username
|
|
12
|
+
password: Password
|
|
13
|
+
brand_name: ShipStation
|
|
14
|
+
description: Integrate with ShipStation to automate your shipping workflows from a single dashboard.
|
|
15
|
+
capture_at_notification: Capture payment when shipment notification received from ShipStation
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "benchmark"
|
|
7
|
+
gem "brakeman", require: false
|
|
8
|
+
gem "bundler-audit", require: false
|
|
9
|
+
gem "propshaft"
|
|
10
|
+
gem "rails-controller-testing"
|
|
11
|
+
gem "rspec-xsd"
|
|
12
|
+
gem "spree_dev_tools"
|
|
13
|
+
gem "standard"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "spree", "~> 5.2.0"
|
|
16
|
+
gem "spree_admin", "~> 5.2.0"
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "benchmark"
|
|
7
|
+
gem "brakeman", require: false
|
|
8
|
+
gem "bundler-audit", require: false
|
|
9
|
+
gem "propshaft"
|
|
10
|
+
gem "rails-controller-testing"
|
|
11
|
+
gem "rspec-xsd"
|
|
12
|
+
gem "spree_dev_tools"
|
|
13
|
+
gem "standard"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "spree", "~> 5.3.0"
|
|
16
|
+
gem "spree_admin", "~> 5.3.0"
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "benchmark"
|
|
7
|
+
gem "brakeman", require: false
|
|
8
|
+
gem "bundler-audit", require: false
|
|
9
|
+
gem "propshaft"
|
|
10
|
+
gem "rails-controller-testing"
|
|
11
|
+
gem "rspec-xsd"
|
|
12
|
+
gem "spree_dev_tools"
|
|
13
|
+
gem "standard"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "spree", "~> 5.4.0"
|
|
16
|
+
gem "spree_admin", "~> 5.4.0"
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
require "spree/core"
|
|
7
|
+
isolate_namespace Spree
|
|
8
|
+
|
|
9
|
+
# Deliberately not "spree-shipstation": engine_name generates route helper
|
|
10
|
+
# prefixes and must be a valid Ruby identifier, so it cannot contain a dash.
|
|
11
|
+
engine_name "spree_shipstation"
|
|
12
|
+
|
|
13
|
+
# use rspec for tests
|
|
14
|
+
config.generators do |g|
|
|
15
|
+
g.test_framework :rspec
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "spree_shipstation.assets" do |app|
|
|
19
|
+
app.config.assets.precompile += %w[spree_shipstation_manifest] if app.config.respond_to?(:assets)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.activate
|
|
23
|
+
# Three levels up from lib/spree/shipstation/ to reach the gem root.
|
|
24
|
+
Dir.glob(File.join(File.dirname(__FILE__), "../../../app/**/*_decorator*.rb")).sort.each do |c|
|
|
25
|
+
Rails.configuration.cache_classes ? require(c) : load(c)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
config.to_prepare(&method(:activate).to_proc)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
class ShipmentNotFoundError < Error
|
|
8
|
+
def initialize(shipment_number, *args)
|
|
9
|
+
super("Could not find shipment with number #{shipment_number}", *args)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class PaymentError < Error
|
|
14
|
+
def initialize(payment, *args)
|
|
15
|
+
super("Could not process payment #{payment.id}", *args)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class MissingTrackingNumberError < Error
|
|
20
|
+
def initialize(*args)
|
|
21
|
+
super("Tracking number is required", *args)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
class ShipmentNotice
|
|
6
|
+
attr_reader :shipment_number, :shipment_tracking, :store
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def from_payload(params, store:)
|
|
10
|
+
new(
|
|
11
|
+
# ShipStation's webhook param is named `order_number` but its value is the
|
|
12
|
+
# shipment number — it mirrors the <OrderNumber> field from the export XML.
|
|
13
|
+
shipment_number: params[:order_number],
|
|
14
|
+
shipment_tracking: params[:tracking_number],
|
|
15
|
+
store: store
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(shipment_number:, shipment_tracking:, store:)
|
|
21
|
+
@shipment_number = shipment_number
|
|
22
|
+
@shipment_tracking = shipment_tracking
|
|
23
|
+
@store = store
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def apply
|
|
27
|
+
raise ShipmentNotFoundError, shipment_number unless shipment
|
|
28
|
+
|
|
29
|
+
::Spree::Shipment.transaction do
|
|
30
|
+
ship_shipment
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
shipment
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def shipment
|
|
39
|
+
@shipment ||= store.shipments.find_by(number: shipment_number)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ship_shipment
|
|
43
|
+
raise MissingTrackingNumberError if shipment_tracking.blank?
|
|
44
|
+
|
|
45
|
+
# Payment capture is performed synchronously, inside the webhook request and
|
|
46
|
+
# the surrounding transaction, so a capture failure aborts the ship and is
|
|
47
|
+
# reported back to ShipStation as an error (HTTP 400) rather than silently
|
|
48
|
+
# shipping an uncaptured order. ShipStation retries failed webhooks, so the
|
|
49
|
+
# operation is written to be safe to repeat: an already-shipped shipment is
|
|
50
|
+
# not re-shipped (see below) and capture only targets still-pending payments.
|
|
51
|
+
capture_pending_payments! if ::Spree::Config.auto_capture_on_dispatch
|
|
52
|
+
|
|
53
|
+
shipment.tracking = shipment_tracking
|
|
54
|
+
shipment.save!
|
|
55
|
+
|
|
56
|
+
shipment.ship! unless shipment.shipped?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def capture_pending_payments!
|
|
60
|
+
shipment.order.payments.pending.each do |payment|
|
|
61
|
+
payment.capture!
|
|
62
|
+
rescue ::Spree::Core::GatewayError
|
|
63
|
+
raise PaymentError.new(payment)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
factory :shipstation_integration, class: Spree::Integrations::Shipstation do
|
|
3
|
+
active { true }
|
|
4
|
+
preferred_username { "myusernameisvalid23kd-ws" }
|
|
5
|
+
preferred_password { "aWc2yNoc27tLisYeT-J@4fjyDN6JZHj@2t-6YFXArFXNUsZBs-G-r" }
|
|
6
|
+
|
|
7
|
+
store { Spree::Store.default }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pagy"
|
|
4
|
+
|
|
5
|
+
require "spree_core"
|
|
6
|
+
require "spree_extension"
|
|
7
|
+
|
|
8
|
+
require "spree/shipstation/version"
|
|
9
|
+
require "spree/shipstation/engine"
|
|
10
|
+
require "spree/shipstation/errors"
|
|
11
|
+
require "spree/shipstation/shipment_notice"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Bundler auto-requires a gem by its *name*, so `gem "spree-shipstation"` in a host
|
|
4
|
+
# Gemfile issues `require "spree-shipstation"`. The gem's real entry point is
|
|
5
|
+
# `spree/shipstation` (matching the Spree::Shipstation namespace), so this shim keeps
|
|
6
|
+
# the default `Bundler.require` working without every consumer having to write
|
|
7
|
+
# `gem "spree-shipstation", require: "spree/shipstation"`.
|
|
8
|
+
require "spree/shipstation"
|