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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CHANGELOG.md +126 -0
  4. data/CODE_OF_CONDUCT.md +132 -0
  5. data/Gemfile +10 -4
  6. data/README.md +125 -28
  7. data/Rakefile +10 -9
  8. data/app/controllers/spree/shipstation_controller.rb +99 -16
  9. data/app/helpers/spree/shipstation/export_helper.rb +21 -10
  10. data/app/models/spree/integrations/shipstation.rb +64 -25
  11. data/app/presenters/spree/shipstation/export/item_presenter.rb +54 -10
  12. data/app/presenters/spree/shipstation/export/order_presenter.rb +87 -17
  13. data/app/presenters/spree/shipstation/export/weight.rb +20 -1
  14. data/app/views/spree/shipstation/export.xml.builder +9 -7
  15. data/config/initializers/spree.rb +5 -1
  16. data/config/locales/en.yml +4 -5
  17. data/config/routes.rb +12 -2
  18. data/lib/spree/shipstation/engine.rb +4 -13
  19. data/lib/spree/shipstation/errors.rb +52 -2
  20. data/lib/spree/shipstation/export_query.rb +136 -0
  21. data/lib/spree/shipstation/shipment_notice.rb +179 -28
  22. data/lib/spree/shipstation/testing_support/factories/shipstation_integration.rb +2 -0
  23. data/lib/spree/shipstation/version.rb +8 -2
  24. data/lib/spree/shipstation.rb +1 -1
  25. data/spree-shipstation.gemspec +8 -7
  26. metadata +11 -41
  27. data/.github/stale.yml +0 -17
  28. data/.github/workflows/lint.yml +0 -24
  29. data/.github/workflows/security.yml +0 -41
  30. data/.github/workflows/test.yml +0 -32
  31. data/Appraisals +0 -20
  32. data/CLAUDE.md +0 -95
  33. data/app/assets/config/spree_shipstation_manifest.js +0 -1
  34. data/app/models/spree/shipment_decorator.rb +0 -28
  35. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
  36. data/gemfiles/spree_5_2.gemfile +0 -18
  37. data/gemfiles/spree_5_3.gemfile +0 -18
  38. 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
- @shipments.each do |shipment|
5
- order = Spree::Shipstation::Export::OrderPresenter.new(shipment)
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
- image = item.image
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 = image && url_for(image.attachment)
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 value.option_type.presentation
49
- xml.Value value.name
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
- Rails.application.config.spree.integrations << Spree::Integrations::Shipstation
6
+ Spree.integrations << "Spree::Integrations::Shipstation"
3
7
  end
@@ -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
- def initialize(payment, *args)
15
- super("Could not process payment #{payment.id}", *args)
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
- attr_reader :shipment_number, :shipment_tracking, :store
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
- def initialize(shipment_number:, shipment_tracking:, store:)
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
- raise ShipmentNotFoundError, shipment_number unless shipment
108
+ fulfillment = find_fulfillment
109
+ raise MissingTrackingNumberError if shipment_tracking.blank?
28
110
 
29
- ::Spree::Shipment.transaction do
30
- ship_shipment
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
- shipment
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 shipment
39
- @shipment ||= store.shipments.find_by(number: shipment_number)
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
- def ship_shipment
43
- raise MissingTrackingNumberError if shipment_tracking.blank?
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
- # 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
158
+ def find_by_derived_number
159
+ order_of_derived_number&.fulfillments&.detect { |fulfillment| fulfillment.number == shipment_number }
160
+ end
52
161
 
53
- shipment.tracking = shipment_tracking
54
- shipment.save!
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
- shipment.ship! unless shipment.shipped?
168
+ store.orders.find_by(number: match[:order])
57
169
  end
58
170
 
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)
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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  FactoryBot.define do
2
4
  factory :shipstation_integration, class: Spree::Integrations::Shipstation do
3
5
  active { true }
@@ -2,9 +2,15 @@
2
2
 
3
3
  module Spree
4
4
  module Shipstation
5
- # Major version tracks Spree's major version: 5.x supports Spree 5.x.
6
- VERSION = "5.0.0"
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
@@ -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"
@@ -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.1"
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
- s.files = `git ls-files`.split("\n").reject { |f| f.match(/^spec/) && !f.match(%r{^spec/fixtures}) }
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
- spree_version = ">= 5.0", "< 6.0"
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