spree-shipstation 5.0.0 → 6.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 +4 -4
- data/.gitignore +0 -1
- data/CHANGELOG.md +126 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/Gemfile +10 -4
- data/README.md +125 -28
- data/Rakefile +10 -9
- data/app/controllers/spree/shipstation_controller.rb +99 -16
- data/app/helpers/spree/shipstation/export_helper.rb +21 -10
- data/app/models/spree/integrations/shipstation.rb +64 -25
- data/app/presenters/spree/shipstation/export/item_presenter.rb +54 -10
- data/app/presenters/spree/shipstation/export/order_presenter.rb +87 -17
- data/app/presenters/spree/shipstation/export/weight.rb +20 -1
- data/app/views/spree/shipstation/export.xml.builder +9 -7
- data/config/initializers/spree.rb +5 -1
- data/config/locales/en.yml +4 -5
- data/config/routes.rb +12 -2
- data/lib/spree/shipstation/engine.rb +4 -13
- data/lib/spree/shipstation/errors.rb +52 -2
- data/lib/spree/shipstation/export_query.rb +136 -0
- data/lib/spree/shipstation/shipment_notice.rb +179 -28
- data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +2 -0
- data/lib/spree/shipstation/version.rb +8 -2
- data/lib/spree/shipstation.rb +1 -1
- data/spree-shipstation.gemspec +8 -7
- metadata +11 -41
- data/.github/stale.yml +0 -17
- data/.github/workflows/lint.yml +0 -24
- data/.github/workflows/security.yml +0 -41
- data/.github/workflows/test.yml +0 -32
- data/Appraisals +0 -20
- data/CLAUDE.md +0 -95
- data/app/assets/config/spree_shipstation_manifest.js +0 -1
- data/app/models/spree/shipment_decorator.rb +0 -28
- data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
- data/gemfiles/spree_5_2.gemfile +0 -18
- data/gemfiles/spree_5_3.gemfile +0 -18
- data/gemfiles/spree_5_4.gemfile +0 -18
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
xml.instruct!
|
|
2
2
|
|
|
3
3
|
xml.Orders(pages: @pagy.pages) do
|
|
4
|
-
@
|
|
5
|
-
order = Spree::Shipstation::Export::OrderPresenter.new(
|
|
4
|
+
@fulfillments.each do |fulfillment|
|
|
5
|
+
order = Spree::Shipstation::Export::OrderPresenter.new(fulfillment)
|
|
6
6
|
|
|
7
7
|
xml.Order do
|
|
8
8
|
xml.OrderID order.order_id
|
|
@@ -27,13 +27,15 @@ xml.Orders(pages: @pagy.pages) do
|
|
|
27
27
|
xml.Items do
|
|
28
28
|
order.items.each do |item|
|
|
29
29
|
weight = item.weight
|
|
30
|
-
|
|
30
|
+
# The thumbnail may be a video. Its still image is the poster, and is
|
|
31
|
+
# nil for an embedded video, which has no attachment to link to.
|
|
32
|
+
still_image = item.image&.still_image
|
|
31
33
|
|
|
32
34
|
xml.Item do
|
|
33
35
|
xml.SKU item.sku
|
|
34
36
|
xml.Name item.name
|
|
35
37
|
|
|
36
|
-
image_url =
|
|
38
|
+
image_url = still_image && url_for(still_image)
|
|
37
39
|
xml.ImageUrl image_url if image_url
|
|
38
40
|
|
|
39
41
|
xml.Weight weight.value
|
|
@@ -43,10 +45,10 @@ xml.Orders(pages: @pagy.pages) do
|
|
|
43
45
|
|
|
44
46
|
if item.option_values.present?
|
|
45
47
|
xml.Options do
|
|
46
|
-
item.option_values.each do |value|
|
|
48
|
+
item.option_values.each do |name, value|
|
|
47
49
|
xml.Option do
|
|
48
|
-
xml.Name
|
|
49
|
-
xml.Value value
|
|
50
|
+
xml.Name name
|
|
51
|
+
xml.Value value
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
end
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Spree resolves registered entries to classes itself, so the class name string
|
|
4
|
+
# avoids loading the model before the app has finished booting.
|
|
1
5
|
Rails.application.config.after_initialize do
|
|
2
|
-
|
|
6
|
+
Spree.integrations << "Spree::Integrations::Shipstation"
|
|
3
7
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
en:
|
|
3
3
|
spree:
|
|
4
|
+
integrations:
|
|
5
|
+
shipstation:
|
|
6
|
+
description: Integrate with ShipStation to automate your shipping workflows from a single dashboard.
|
|
4
7
|
admin:
|
|
5
8
|
integrations:
|
|
6
9
|
shipstation:
|
|
10
|
+
brand_name: ShipStation
|
|
7
11
|
must_contain_at_least_one_special_character: must contain at least one special character
|
|
8
12
|
must_contain_at_least_one_uppercase_letter: must contain at least one uppercase letter
|
|
9
13
|
must_contain_at_least_one_number: must contain at least one number
|
|
10
14
|
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
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# ShipStation pulls orders and posts ship notices to the same URL. The bare path
|
|
4
|
+
# belongs to the default store. The path that names an integration (its prefixed
|
|
5
|
+
# id) belongs to that integration's store, which is how a second store gets an
|
|
6
|
+
# endpoint of its own.
|
|
7
|
+
#
|
|
8
|
+
# The export is XML whether or not the URL ends in +.xml+. Without the default,
|
|
9
|
+
# a pull that sends a browser-like Accept header would be refused with a 406.
|
|
3
10
|
Spree::Core::Engine.add_routes do
|
|
4
|
-
get "/shipstation", to: "shipstation#export"
|
|
5
|
-
post "/shipstation", to: "shipstation#shipnotify"
|
|
11
|
+
get "/shipstation(.:format)", to: "shipstation#export", defaults: {format: "xml"}
|
|
12
|
+
post "/shipstation(.:format)", to: "shipstation#shipnotify"
|
|
13
|
+
|
|
14
|
+
get "/shipstation/:integration_id(.:format)", to: "shipstation#export", defaults: {format: "xml"}
|
|
15
|
+
post "/shipstation/:integration_id(.:format)", to: "shipstation#shipnotify"
|
|
6
16
|
end
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
|
+
##
|
|
6
|
+
# Rails engine for the ShipStation integration. It makes the gem's +app+ and
|
|
7
|
+
# +config+ directories part of the host application.
|
|
8
|
+
#
|
|
5
9
|
class Engine < ::Rails::Engine
|
|
6
10
|
require "spree/core"
|
|
7
11
|
isolate_namespace Spree
|
|
@@ -14,19 +18,6 @@ module Spree
|
|
|
14
18
|
config.generators do |g|
|
|
15
19
|
g.test_framework :rspec
|
|
16
20
|
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
21
|
end
|
|
31
22
|
end
|
|
32
23
|
end
|
|
@@ -2,20 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
|
+
##
|
|
6
|
+
# Base error for ShipStation integration failures returned as HTTP 400.
|
|
7
|
+
#
|
|
5
8
|
class Error < StandardError; end
|
|
6
9
|
|
|
10
|
+
##
|
|
11
|
+
# Raised when the shipnotify payload names a shipment that does not exist.
|
|
12
|
+
#
|
|
7
13
|
class ShipmentNotFoundError < Error
|
|
14
|
+
##
|
|
15
|
+
# @param shipment_number [String] the shipment number from the payload
|
|
16
|
+
#
|
|
8
17
|
def initialize(shipment_number, *args)
|
|
9
18
|
super("Could not find shipment with number #{shipment_number}", *args)
|
|
10
19
|
end
|
|
11
20
|
end
|
|
12
21
|
|
|
22
|
+
##
|
|
23
|
+
# Raised when the ship notice's +OrderID+ names a fulfillment of a different
|
|
24
|
+
# order from the one its order number names.
|
|
25
|
+
#
|
|
26
|
+
class ShipmentMismatchError < Error
|
|
27
|
+
##
|
|
28
|
+
# @param shipment_number [String] the order number from the notice
|
|
29
|
+
# @param shipment_id [Integer] the +OrderID+ from the notice
|
|
30
|
+
#
|
|
31
|
+
def initialize(shipment_number, shipment_id, *args)
|
|
32
|
+
super("Shipment #{shipment_number} does not match order id #{shipment_id}", *args)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# Raised when the shipnotify payload names a shipment Spree has canceled.
|
|
38
|
+
#
|
|
39
|
+
class FulfillmentCanceledError < Error
|
|
40
|
+
##
|
|
41
|
+
# @param shipment_number [String] the number of the canceled shipment
|
|
42
|
+
#
|
|
43
|
+
def initialize(shipment_number, *args)
|
|
44
|
+
super("Shipment #{shipment_number} is canceled", *args)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Raised when Spree refuses to fulfil the shipment, for instance because the
|
|
50
|
+
# order is not paid or holds backordered items. The message is Spree's own.
|
|
51
|
+
#
|
|
52
|
+
class FulfillmentRefusedError < Error; end
|
|
53
|
+
|
|
54
|
+
##
|
|
55
|
+
# Raised when a payment cannot be captured during shipnotify.
|
|
56
|
+
#
|
|
13
57
|
class PaymentError < Error
|
|
14
|
-
|
|
15
|
-
|
|
58
|
+
##
|
|
59
|
+
# @param message [String] why the capture failed, as the gateway reported it
|
|
60
|
+
#
|
|
61
|
+
def initialize(message, *args)
|
|
62
|
+
super("Could not capture payment: #{message}", *args)
|
|
16
63
|
end
|
|
17
64
|
end
|
|
18
65
|
|
|
66
|
+
##
|
|
67
|
+
# Raised when shipnotify is called without a tracking number.
|
|
68
|
+
#
|
|
19
69
|
class MissingTrackingNumberError < Error
|
|
20
70
|
def initialize(*args)
|
|
21
71
|
super("Tracking number is required", *args)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
module Shipstation
|
|
5
|
+
##
|
|
6
|
+
# Selects the parcels ShipStation should pull: the store's fulfillments that
|
|
7
|
+
# Spree will let ship right now.
|
|
8
|
+
#
|
|
9
|
+
# Spree 6 keeps no "ready" status. A fulfillment is +unfulfilled+ until
|
|
10
|
+
# someone hands it over, and whether it can go out is decided when it is
|
|
11
|
+
# fulfilled. This query asks the same questions ahead of time, so ShipStation
|
|
12
|
+
# is not offered parcels that Spree would refuse at the ship notice. A parcel
|
|
13
|
+
# is exported when all of these hold:
|
|
14
|
+
#
|
|
15
|
+
# * it is one of the store's fulfillments and is still +unfulfilled+;
|
|
16
|
+
# * its order is placed, which leaves out drafts and canceled orders;
|
|
17
|
+
# * its order is paid: +paid+, +overcharged+ or +partially_refunded+, or it
|
|
18
|
+
# owes nothing (+total = 0+, whose payment status stays +none+), or, on a
|
|
19
|
+
# store that captures on dispatch or manually, it is +authorized+ or
|
|
20
|
+
# +partially_paid+ (mixed tender: store credit captured at checkout, say,
|
|
21
|
+
# and a card authorized for the rest). A payment method that overrides
|
|
22
|
+
# the store's setting is not consulted;
|
|
23
|
+
# * none of its items is backordered;
|
|
24
|
+
# * its selected delivery method is not a pickup or a pickup point.
|
|
25
|
+
#
|
|
26
|
+
# These are most of Spree's fulfil checks, not all of them, so the ship
|
|
27
|
+
# notice may still be refused, with Spree's reason. Spree also checks, when
|
|
28
|
+
# the parcel is fulfilled, that the stock location has the units on hand,
|
|
29
|
+
# and it compares the net captured amount (net of refunds) with what was
|
|
30
|
+
# due: a partially refunded order is exported but may fall short, and a
|
|
31
|
+
# partially paid one ships only while the rest is authorized on payment
|
|
32
|
+
# methods that defer capture.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# Spree::Shipstation::ExportQuery.new(store: store, from: 1.day.ago).call
|
|
36
|
+
#
|
|
37
|
+
class ExportQuery
|
|
38
|
+
PAID_STATUSES = %w[paid overcharged partially_refunded].freeze
|
|
39
|
+
private_constant :PAID_STATUSES
|
|
40
|
+
|
|
41
|
+
# Shippable as well when the store captures on dispatch or manually.
|
|
42
|
+
DEFERRED_CAPTURE_STATUSES = %w[authorized partially_paid].freeze
|
|
43
|
+
private_constant :DEFERRED_CAPTURE_STATUSES
|
|
44
|
+
|
|
45
|
+
PRELOADS = {
|
|
46
|
+
order: [:bill_address, :ship_address, :fulfillments],
|
|
47
|
+
selected_delivery_rate: :delivery_method,
|
|
48
|
+
fulfillment_items: {
|
|
49
|
+
line_item: {
|
|
50
|
+
variant: [{product: :primary_media}, :primary_media, {option_values: :option_type}]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}.freeze
|
|
54
|
+
private_constant :PRELOADS
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @param store [Spree::Store] the store whose fulfillments are exported
|
|
58
|
+
# @param from [Time, NilClass] start of the date window
|
|
59
|
+
# @param to [Time, NilClass] end of the date window
|
|
60
|
+
#
|
|
61
|
+
def initialize(store:, from: nil, to: nil)
|
|
62
|
+
@store = store
|
|
63
|
+
@from = from
|
|
64
|
+
@to = to
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# The exportable parcels, oldest update first, with everything the export
|
|
69
|
+
# presenters read already loaded.
|
|
70
|
+
#
|
|
71
|
+
# A parcel falls in the window when its own +updated_at+ or its order's does.
|
|
72
|
+
# With neither +from+ nor +to+ there is no window, and with only one of them
|
|
73
|
+
# the window is open-ended.
|
|
74
|
+
#
|
|
75
|
+
# @return [ActiveRecord::Relation<Spree::Fulfillment>]
|
|
76
|
+
#
|
|
77
|
+
def call
|
|
78
|
+
within_window(shippable)
|
|
79
|
+
.reorder(updated_at: :asc, id: :asc)
|
|
80
|
+
.preload(PRELOADS)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
attr_reader :store, :from, :to
|
|
86
|
+
|
|
87
|
+
def shippable
|
|
88
|
+
store.fulfillments
|
|
89
|
+
.unfulfilled
|
|
90
|
+
.merge(Spree::Order.placed_orders)
|
|
91
|
+
.merge(paid_orders)
|
|
92
|
+
.where.not(id: backordered_fulfillment_ids)
|
|
93
|
+
.where.not(id: pickup_fulfillment_ids)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A free order's payment status stays +none+, but Spree ships it.
|
|
97
|
+
def paid_orders
|
|
98
|
+
Spree::Order.where(payment_status: paid_statuses).or(Spree::Order.where(total: 0))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# An order that is authorized, in full or for the part not captured at
|
|
102
|
+
# checkout, can ship when the store charges it at dispatch or leaves it to
|
|
103
|
+
# staff: Spree lets it through when every pending payment defers capture.
|
|
104
|
+
# On a store that captures at checkout, authorized money is not yet paid.
|
|
105
|
+
def paid_statuses
|
|
106
|
+
store.capture_at_checkout? ? PAID_STATUSES : PAID_STATUSES + DEFERRED_CAPTURE_STATUSES
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The +fulfillment_id IS NOT NULL+ guards matter: a single NULL in a +NOT IN+
|
|
110
|
+
# list makes the comparison unknown for every row, which would export nothing.
|
|
111
|
+
def backordered_fulfillment_ids
|
|
112
|
+
Spree::FulfillmentItem.backordered.where.not(fulfillment_id: nil).select(:fulfillment_id)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Only the delivery method the customer chose counts. A parcel may carry a
|
|
116
|
+
# pickup rate among the options it was offered. Methods are read with
|
|
117
|
+
# deleted ones, since orders outlive the method they were placed with.
|
|
118
|
+
def pickup_fulfillment_ids
|
|
119
|
+
pickups = Spree::DeliveryMethod.with_deleted.with_provider(:pickup?)
|
|
120
|
+
.or(Spree::DeliveryMethod.with_deleted.with_provider(:pickup_point?))
|
|
121
|
+
|
|
122
|
+
Spree::DeliveryRate
|
|
123
|
+
.where(selected: true, delivery_method_id: pickups.select(:id))
|
|
124
|
+
.where.not(fulfillment_id: nil)
|
|
125
|
+
.select(:fulfillment_id)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def within_window(scope)
|
|
129
|
+
return scope if from.nil? && to.nil?
|
|
130
|
+
|
|
131
|
+
window = from..to
|
|
132
|
+
scope.where(updated_at: window).or(scope.where(Spree::Order.table_name => {updated_at: window}))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -2,67 +2,218 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
|
+
##
|
|
6
|
+
# Applies a ShipStation shipnotify payload to a Spree fulfillment.
|
|
7
|
+
#
|
|
8
|
+
# The payload's order number is the +OrderNumber+ the export sent, which is a
|
|
9
|
+
# fulfillment's number, and its order id, when ShipStation sends one, is the
|
|
10
|
+
# +OrderID+ the export sent, which is the fulfillment's id. The notice
|
|
11
|
+
# resolves the fulfillment within the store:
|
|
12
|
+
#
|
|
13
|
+
# * without an order id, by its number: the number stored on a fulfillment
|
|
14
|
+
# from before Spree 6, else the number Spree derives from its order;
|
|
15
|
+
# * with one, by that id, which is authoritative. A derived number is the
|
|
16
|
+
# parcel's position in its order, so when Spree destroys an earlier parcel
|
|
17
|
+
# (every item moved out of it) the later ones take new numbers, and an old
|
|
18
|
+
# number may now name a sibling. The number still has to name the
|
|
19
|
+
# fulfillment's order; a fulfillment that no longer exists is not found,
|
|
20
|
+
# never matched on its number instead.
|
|
21
|
+
#
|
|
22
|
+
# Then, under the order's row lock (which serializes ShipStation's retries):
|
|
23
|
+
#
|
|
24
|
+
# * a fulfillment that is already fulfilled or delivered (or still carries
|
|
25
|
+
# the 5.x +shipped+ status) only has its tracking recorded, so a retry or
|
|
26
|
+
# a re-label does not fulfil it or publish the event again;
|
|
27
|
+
# * a canceled one is refused;
|
|
28
|
+
# * any other goes through Spree's fulfil workflow, which checks the order is
|
|
29
|
+
# paid and in stock, records the tracking and carrier, captures payments
|
|
30
|
+
# that capture on dispatch, and publishes +fulfillment.fulfilled+ with
|
|
31
|
+
# +notify_customer+ set. The host app's subscriber to that event decides
|
|
32
|
+
# whether, and how, to tell the customer.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# Spree::Shipstation::ShipmentNotice.from_payload(
|
|
36
|
+
# {order_number: "R1001-F1", order_id: 1001, tracking_number: "1Z999AA10123456784", carrier: "UPS"},
|
|
37
|
+
# store: store
|
|
38
|
+
# ).apply
|
|
39
|
+
#
|
|
5
40
|
class ShipmentNotice
|
|
6
|
-
|
|
41
|
+
# Splits a derived fulfillment number into its order's number and its
|
|
42
|
+
# position. The order part is greedy, so an order number that itself ends
|
|
43
|
+
# in "-F9" still parses. Only the order is used: the parcel is matched on
|
|
44
|
+
# Spree's own derived number rather than on the position.
|
|
45
|
+
DERIVED_NUMBER = /\A(?<order>.+)-F(?<position>\d+)\z/
|
|
46
|
+
private_constant :DERIVED_NUMBER
|
|
47
|
+
|
|
48
|
+
attr_reader :shipment_number, :shipment_id, :shipment_tracking, :shipment_carrier, :store
|
|
7
49
|
|
|
8
50
|
class << self
|
|
51
|
+
##
|
|
52
|
+
# Builds a notice from the shipnotify webhook params.
|
|
53
|
+
#
|
|
54
|
+
# ShipStation's webhook param is named +order_number+ but its value is
|
|
55
|
+
# the fulfillment number — it mirrors the +<OrderNumber>+ field from the
|
|
56
|
+
# export XML. Likewise +order_id+ is the +<OrderID>+ field, the
|
|
57
|
+
# fulfillment's id, which ShipStation sends in the notice's XML body.
|
|
58
|
+
#
|
|
59
|
+
# @param params [Hash] webhook params: +:order_number+, +:tracking_number+,
|
|
60
|
+
# and optionally +:carrier+ and +:order_id+
|
|
61
|
+
# @param store [Spree::Store] store that owns the fulfillment
|
|
62
|
+
# @return [Spree::Shipstation::ShipmentNotice]
|
|
63
|
+
#
|
|
9
64
|
def from_payload(params, store:)
|
|
10
65
|
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
66
|
shipment_number: params[:order_number],
|
|
67
|
+
shipment_id: params[:order_id],
|
|
14
68
|
shipment_tracking: params[:tracking_number],
|
|
69
|
+
shipment_carrier: params[:carrier],
|
|
15
70
|
store: store
|
|
16
71
|
)
|
|
17
72
|
end
|
|
18
73
|
end
|
|
19
74
|
|
|
20
|
-
|
|
75
|
+
##
|
|
76
|
+
# @param shipment_number [String] Spree fulfillment number
|
|
77
|
+
# @param shipment_tracking [String] carrier tracking number
|
|
78
|
+
# @param store [Spree::Store] store that owns the fulfillment
|
|
79
|
+
# @param shipment_carrier [String, NilClass] ShipStation's carrier name
|
|
80
|
+
# @param shipment_id [Integer, NilClass] ShipStation's +OrderID+, the
|
|
81
|
+
# fulfillment's id; when given, it decides which fulfillment is meant
|
|
82
|
+
#
|
|
83
|
+
def initialize(shipment_number:, shipment_tracking:, store:, shipment_carrier: nil, shipment_id: nil)
|
|
21
84
|
@shipment_number = shipment_number
|
|
85
|
+
@shipment_id = shipment_id
|
|
22
86
|
@shipment_tracking = shipment_tracking
|
|
87
|
+
@shipment_carrier = shipment_carrier
|
|
23
88
|
@store = store
|
|
24
89
|
end
|
|
25
90
|
|
|
91
|
+
##
|
|
92
|
+
# Records the tracking number and fulfils the parcel, unless it already is.
|
|
93
|
+
#
|
|
94
|
+
# A gateway decline rolls back everything Spree wrote under the order lock,
|
|
95
|
+
# so the parcel stays unfulfilled, its payment stays pending and the retry
|
|
96
|
+
# starts from the same place.
|
|
97
|
+
#
|
|
98
|
+
# @return [Spree::Fulfillment] the updated fulfillment
|
|
99
|
+
# @raise [Spree::Shipstation::ShipmentNotFoundError] when the fulfillment is missing
|
|
100
|
+
# @raise [Spree::Shipstation::ShipmentMismatchError] when the order id names
|
|
101
|
+
# a fulfillment of another order than the number does
|
|
102
|
+
# @raise [Spree::Shipstation::MissingTrackingNumberError] when tracking is blank
|
|
103
|
+
# @raise [Spree::Shipstation::FulfillmentCanceledError] when the fulfillment is canceled
|
|
104
|
+
# @raise [Spree::Shipstation::FulfillmentRefusedError] when Spree refuses to fulfil it
|
|
105
|
+
# @raise [Spree::Shipstation::PaymentError] when a payment cannot be captured
|
|
106
|
+
#
|
|
26
107
|
def apply
|
|
27
|
-
|
|
108
|
+
fulfillment = find_fulfillment
|
|
109
|
+
raise MissingTrackingNumberError if shipment_tracking.blank?
|
|
28
110
|
|
|
29
|
-
|
|
30
|
-
|
|
111
|
+
fulfillment.order.with_lock do
|
|
112
|
+
# Reloaded under the lock: a retry that waited for it sees what the
|
|
113
|
+
# first request did.
|
|
114
|
+
fulfillment.reload
|
|
115
|
+
apply_to(fulfillment)
|
|
31
116
|
end
|
|
32
|
-
|
|
33
|
-
|
|
117
|
+
rescue ::Spree::Core::GatewayError => e
|
|
118
|
+
# Rescued outside the lock, as Spree's own fulfil endpoint does. Inside
|
|
119
|
+
# it, the transaction would commit a fulfilled parcel whose capture failed.
|
|
120
|
+
raise PaymentError, e.message
|
|
34
121
|
end
|
|
35
122
|
|
|
36
123
|
private
|
|
37
124
|
|
|
38
|
-
def
|
|
39
|
-
|
|
125
|
+
def find_fulfillment
|
|
126
|
+
# New fulfillments store no number, so looking up a blank one would match
|
|
127
|
+
# every one of them and fulfil an arbitrary parcel.
|
|
128
|
+
raise ShipmentNotFoundError, shipment_number if shipment_number.blank?
|
|
129
|
+
return find_by_shipment_id if shipment_id
|
|
130
|
+
|
|
131
|
+
find_by_stored_number || find_by_derived_number || raise(ShipmentNotFoundError, shipment_number)
|
|
40
132
|
end
|
|
41
133
|
|
|
42
|
-
|
|
43
|
-
|
|
134
|
+
# The id is checked against the order the number names, which survives a
|
|
135
|
+
# renumbering even when the parcel's own derived number does not. It never
|
|
136
|
+
# falls back to the number: the parcel the id named may have been destroyed,
|
|
137
|
+
# and its number handed to a sibling.
|
|
138
|
+
def find_by_shipment_id
|
|
139
|
+
order = find_by_stored_number&.order || order_of_derived_number
|
|
140
|
+
raise ShipmentNotFoundError, shipment_number unless order
|
|
141
|
+
|
|
142
|
+
fulfillment = store.fulfillments.find_by(id: shipment_id)
|
|
143
|
+
raise ShipmentNotFoundError, shipment_number unless fulfillment
|
|
144
|
+
raise ShipmentMismatchError.new(shipment_number, shipment_id) unless fulfillment.order_id == order.id
|
|
145
|
+
|
|
146
|
+
fulfillment
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Parcels created before Spree 6 keep the number stored on them. Spree
|
|
150
|
+
# keeps the column only as legacy data, to be dropped in 6.1, which this
|
|
151
|
+
# gem's +~> 6.0+ admits; once it is gone, numbers are only derived.
|
|
152
|
+
def find_by_stored_number
|
|
153
|
+
return unless ::Spree::Fulfillment.column_names.include?("number")
|
|
154
|
+
|
|
155
|
+
store.fulfillments.find_by(number: shipment_number)
|
|
156
|
+
end
|
|
44
157
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
158
|
+
def find_by_derived_number
|
|
159
|
+
order_of_derived_number&.fulfillments&.detect { |fulfillment| fulfillment.number == shipment_number }
|
|
160
|
+
end
|
|
52
161
|
|
|
53
|
-
|
|
54
|
-
|
|
162
|
+
# The store's order a derived number names, whether or not any of its
|
|
163
|
+
# parcels derives that number now.
|
|
164
|
+
def order_of_derived_number
|
|
165
|
+
match = DERIVED_NUMBER.match(shipment_number.to_s)
|
|
166
|
+
return unless match
|
|
55
167
|
|
|
56
|
-
|
|
168
|
+
store.orders.find_by(number: match[:order])
|
|
57
169
|
end
|
|
58
170
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
raise
|
|
171
|
+
def apply_to(fulfillment)
|
|
172
|
+
if already_shipped?(fulfillment)
|
|
173
|
+
record_tracking(fulfillment)
|
|
174
|
+
elsif fulfillment.canceled?
|
|
175
|
+
raise FulfillmentCanceledError, fulfillment.number
|
|
176
|
+
else
|
|
177
|
+
fulfil(fulfillment)
|
|
64
178
|
end
|
|
65
179
|
end
|
|
180
|
+
|
|
181
|
+
# "shipped" is a 5.x status, left on a parcel until Spree's upgrade task
|
|
182
|
+
# (+spree:upgrade+) renames it "fulfilled". Such a parcel has gone out.
|
|
183
|
+
def already_shipped?(fulfillment)
|
|
184
|
+
fulfillment.fulfilled_or_delivered? || fulfillment.status == "shipped"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def record_tracking(fulfillment)
|
|
188
|
+
result = ::Spree.delivery_upsert_primary_service.call(
|
|
189
|
+
fulfillment: fulfillment, tracking: shipment_tracking, carrier: tracking_carrier
|
|
190
|
+
)
|
|
191
|
+
raise FulfillmentRefusedError, result.error.to_s if result.failure?
|
|
192
|
+
|
|
193
|
+
fulfillment.reload
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Never passes +items:+ or +force:+: a partial list would split the parcel
|
|
197
|
+
# and ship a new fulfillment under a different number, and forcing would
|
|
198
|
+
# ship an unpaid or backordered one.
|
|
199
|
+
def fulfil(fulfillment)
|
|
200
|
+
result = ::Spree.fulfillment_fulfill_workflow.call(
|
|
201
|
+
fulfillment: fulfillment,
|
|
202
|
+
tracking: shipment_tracking,
|
|
203
|
+
tracking_carrier: tracking_carrier,
|
|
204
|
+
notify_customer: true
|
|
205
|
+
)
|
|
206
|
+
raise FulfillmentRefusedError, result.error.to_s if result.failure?
|
|
207
|
+
|
|
208
|
+
result.value
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# ShipStation's carrier name as a Spree.tracking_carriers key, or nil when
|
|
212
|
+
# Spree has no such carrier, in which case it detects one from the number.
|
|
213
|
+
def tracking_carrier
|
|
214
|
+
key = shipment_carrier.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
|
|
215
|
+
key if ::Spree.tracking_carriers.key?(key)
|
|
216
|
+
end
|
|
66
217
|
end
|
|
67
218
|
end
|
|
68
219
|
end
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module Spree
|
|
4
4
|
module Shipstation
|
|
5
|
-
# Major version tracks Spree's major version:
|
|
6
|
-
|
|
5
|
+
# Major version tracks Spree's major version: 6.x supports Spree 6; 5.0.x
|
|
6
|
+
# lives on +5-0-stable+.
|
|
7
|
+
VERSION = "6.0.0"
|
|
7
8
|
|
|
9
|
+
##
|
|
10
|
+
# Gem version as a comparable +Gem::Version+.
|
|
11
|
+
#
|
|
12
|
+
# @return [Gem::Version]
|
|
13
|
+
#
|
|
8
14
|
def self.version
|
|
9
15
|
Gem::Version.new(VERSION)
|
|
10
16
|
end
|
data/lib/spree/shipstation.rb
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
require "pagy"
|
|
4
4
|
|
|
5
5
|
require "spree_core"
|
|
6
|
-
require "spree_extension"
|
|
7
6
|
|
|
8
7
|
require "spree/shipstation/version"
|
|
9
8
|
require "spree/shipstation/engine"
|
|
10
9
|
require "spree/shipstation/errors"
|
|
10
|
+
require "spree/shipstation/export_query"
|
|
11
11
|
require "spree/shipstation/shipment_notice"
|
data/spree-shipstation.gemspec
CHANGED
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
s.version = Spree::Shipstation.version
|
|
12
12
|
s.summary = "ShipStation integration for Spree e-commerce"
|
|
13
13
|
s.description = "Connects Spree stores to ShipStation via an XML export endpoint and shipnotify webhook, enabling automated label creation and shipment tracking."
|
|
14
|
-
s.required_ruby_version = ">= 3.
|
|
14
|
+
s.required_ruby_version = ">= 3.3"
|
|
15
15
|
|
|
16
16
|
s.author = "Matthew Kennedy"
|
|
17
17
|
s.email = "m.kennedy@me.com"
|
|
@@ -25,12 +25,13 @@ Gem::Specification.new do |s|
|
|
|
25
25
|
"rubygems_mfa_required" => "true"
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# The specs (all but their fixtures), the design docs, CI config and the agent
|
|
29
|
+
# guide stay in the repository, out of the gem.
|
|
30
|
+
s.files = `git ls-files`.split("\n").reject do |f|
|
|
31
|
+
(f.match(/^spec/) && !f.match(%r{^spec/fixtures})) || f.match(%r{^(docs|\.github)/}) || f == "CLAUDE.md"
|
|
32
|
+
end
|
|
29
33
|
s.require_path = "lib"
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
s.add_dependency "pagy", ">= 43.0", "< 45.0"
|
|
34
|
-
s.add_dependency "spree", spree_version
|
|
35
|
-
s.add_dependency "spree_extension"
|
|
35
|
+
s.add_dependency "pagy", ">= 43.6", "< 45"
|
|
36
|
+
s.add_dependency "spree", "~> 6.0"
|
|
36
37
|
end
|