spree-shipstation 5.0.1 → 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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -1
  3. data/CHANGELOG.md +115 -0
  4. data/Gemfile +8 -4
  5. data/README.md +119 -28
  6. data/Rakefile +8 -9
  7. data/app/controllers/spree/shipstation_controller.rb +89 -19
  8. data/app/helpers/spree/shipstation/export_helper.rb +4 -10
  9. data/app/models/spree/integrations/shipstation.rb +50 -29
  10. data/app/presenters/spree/shipstation/export/item_presenter.rb +20 -15
  11. data/app/presenters/spree/shipstation/export/order_presenter.rb +33 -19
  12. data/app/views/spree/shipstation/export.xml.builder +9 -7
  13. data/config/initializers/spree.rb +3 -1
  14. data/config/locales/en.yml +4 -5
  15. data/config/routes.rb +12 -2
  16. data/lib/spree/shipstation/engine.rb +2 -14
  17. data/lib/spree/shipstation/errors.rb +36 -4
  18. data/lib/spree/shipstation/export_query.rb +136 -0
  19. data/lib/spree/shipstation/shipment_notice.rb +162 -41
  20. data/lib/spree/shipstation/version.rb +3 -2
  21. data/lib/spree/shipstation.rb +1 -1
  22. data/spree-shipstation.gemspec +8 -7
  23. metadata +10 -42
  24. data/.github/stale.yml +0 -17
  25. data/.github/workflows/lint.yml +0 -24
  26. data/.github/workflows/release.yml +0 -46
  27. data/.github/workflows/security.yml +0 -41
  28. data/.github/workflows/test.yml +0 -32
  29. data/Appraisals +0 -20
  30. data/CLAUDE.md +0 -98
  31. data/app/assets/config/spree_shipstation_manifest.js +0 -1
  32. data/app/models/spree/shipment_decorator.rb +0 -32
  33. data/app/views/spree/admin/integrations/forms/_shipstation.html.erb +0 -6
  34. data/gemfiles/spree_5_2.gemfile +0 -18
  35. data/gemfiles/spree_5_3.gemfile +0 -18
  36. data/gemfiles/spree_5_4.gemfile +0 -18
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59253a22e0dcd58163a5f9e3380f1e1c502f8256e05f7b2d65755b01a4879ec8
4
- data.tar.gz: eb36256ce70cc0ab15858669bf823b4435f2a8d167a0517425af042db83dd95d
3
+ metadata.gz: 1ec0ccf29f471fcbd60126248195ed5adbb10f9ae5cc5301b23b640308ce6f0f
4
+ data.tar.gz: 97d83306702cbbac6b67f7d4e2bca35676063e397e7e13717ad4eaed7e0d3487
5
5
  SHA512:
6
- metadata.gz: 4257f6418fa187a10b4f7b178f4b36d69516ba08d65d826260df6ff05056c9e63b176f97d61ae56c3645471f4868db03118f9d608d39eaed26d1d50bcce8dbad
7
- data.tar.gz: 53537dda148d24860c62affd5b79f041bfce4431956b0a643e48cb3879229957a6ecad3476d5b6ae82b2d6ac6653b04d7f277345d39e0149bcdd5de0017d169f
6
+ metadata.gz: 80e0b2aa61bcc31c1a59249d02522b548899cdea0fa5a7dfb04ba03aa24916105c54434495095f598a54a5d8aa472f58be1cfd2a9eb8c48636efcc8f759eaa84
7
+ data.tar.gz: c3650585bc19e8dd5d89571771b413fa64de828a9f82afd72a899cbb0f751d576367dc8de94bd5d2f18b8e267da6ea84b37cd32a069aa1b12bed3999f530a26f
data/.gitignore CHANGED
@@ -19,5 +19,4 @@ spec/dummy
19
19
  public/spree
20
20
  .ruby-version
21
21
  .ruby-gemset
22
- gemfiles/*.gemfile.lock
23
22
  *.gem
data/CHANGELOG.md CHANGED
@@ -2,6 +2,121 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 6.0.0
6
+
7
+ Spree 6 support. The major version tracks Spree's, so `spree-shipstation` 6.x
8
+ supports Spree 6, and Spree 5 stays on the 5.0.x releases (the `5-0-stable`
9
+ branch).
10
+
11
+ **Nothing ShipStation or the merchant has configured has to change** for the
12
+ default store. The `/shipstation` URL, the credentials, the XML, the `ready`
13
+ status and the `OrderNumber` of every fulfillment ShipStation already holds stay
14
+ as they were. A store other than the default moves to its integration's URL
15
+ (below).
16
+
17
+ **Run `bin/rake spree:upgrade` right after `db:migrate`, before ShipStation next
18
+ polls.** Spree's upgrade tasks move 5.x shipment statuses to fulfillment statuses
19
+ (`ready` and `pending` become `unfulfilled`, `shipped` becomes `fulfilled`),
20
+ backfill the delivery-method providers the pickup rule reads, recompute the
21
+ statuses of orders whose shipments were pending, ready or shipped, encrypt stored
22
+ secrets, and carry `auto_capture_on_dispatch` over to the store's
23
+ `capture_method`. Until then the export is empty. See "Upgrading
24
+ from 5.0" in the README.
25
+
26
+ **Requirements:** Spree `~> 6.0`, Ruby 3.3 or newer, pagy 43.6 or newer. The 5.x
27
+ releases do not install on Spree 6.
28
+
29
+ ### Changed
30
+
31
+ - **Orders are exported per fulfillment**, since Spree 6 replaced shipments with
32
+ fulfillments. `OrderID` is the fulfillment's id and `OrderNumber` its number:
33
+ the number a fulfillment already had before Spree 6, or `R1001-F1` style
34
+ (order number, `-F`, position) for new ones. Totals stay order-level, as before.
35
+ - **Only fulfillments Spree will let ship are exported**: placed, still
36
+ unfulfilled, paid, with nothing backordered, and not a pickup. "Paid" means
37
+ paid, overcharged or partially refunded; or nothing owed; or, on a store whose
38
+ capture method is `on_dispatch` or `manual`, authorized, or partially paid with
39
+ the rest authorized (mixed tender, such as store credit plus a card). As before,
40
+ unpaid or backordered fulfillments do not appear in ShipStation until they can
41
+ ship: 5.x exported only `ready` shipments. Results are ordered by last update,
42
+ oldest first.
43
+ - **Ship notices fulfil through Spree 6's fulfil workflow.** When Spree refuses
44
+ (an unpaid order, backordered items, a canceled fulfillment) the notice is
45
+ answered with `400` and Spree's reason, and nothing is recorded. It is never
46
+ answered `200` for a fulfillment Spree did not ship.
47
+ - **Ship notices are retry-safe.** A notice for a fulfillment that is already
48
+ fulfilled or delivered only records or corrects the tracking number: it does
49
+ not fulfil the parcel or publish the event again. A retry that names a tracking
50
+ number already on another delivery of the same fulfillment is refused with
51
+ `400` ("Tracking number has already been taken"). Concurrent retries are
52
+ serialized under the order's row lock. A parcel still marked `shipped` from 5.x,
53
+ before Spree's upgrade task renames the status, is treated the same way.
54
+ - **A notice without an order number is refused** with `400`. New fulfillments
55
+ store no number, so a blank one would otherwise match any of them.
56
+ - **Capture on dispatch follows Spree 6's `capture_method`**, set on the store or
57
+ overridden per payment method. A declined capture answers `400` ("Could not
58
+ capture payment: ...") and keeps nothing: the fulfillment stays unfulfilled,
59
+ the payment stays pending and the tracking written during the attempt is rolled
60
+ back, so the retry starts from the same place.
61
+ - **Shipped notifications are the host app's to send.** Spree 6 core sends no
62
+ shipped email itself. The fulfil workflow publishes `fulfillment.fulfilled` with
63
+ `notify_customer` set, and your application's subscriber decides whether, and
64
+ how, to tell the customer. On a declined capture core publishes that event
65
+ inside the transaction that is then rolled back, so a subscriber that notifies
66
+ at once can announce a shipment that did not happen (Spree's own fulfil endpoint
67
+ behaves the same).
68
+ - `ImageUrl` uses the media's still image, so a video thumbnail exports its
69
+ poster. It is still a root-relative path.
70
+ - The export returns **50 fulfillments per page**, as documented. (pagy 43, which
71
+ Spree 6 requires, ignores the option the gem used to set this and would have
72
+ given 25.) A `page` that is not a number is read as page 1.
73
+ - The integration appears in Spree 6's dashboard under the shipping group, with a
74
+ description, logo and link to the README. Its form is generated from the
75
+ username and password preferences. Spree 6's migration copies the password into
76
+ `secret_preferences` as plain text, and `bin/rake spree:upgrade` encrypts it
77
+ when Active Record encryption keys are configured.
78
+ - Both endpoints now run on a plain `ActionController::Base` controller, since
79
+ Spree 6 removed the base controller and helper the 5.x one used.
80
+
81
+ ### Added
82
+
83
+ - **`/shipstation/<integration id>`** (`GET` and `POST`, with or without `.xml`)
84
+ serves the store of the active ShipStation integration with that `int_` prefixed
85
+ id. Spree 6 no longer picks the store from the request's host, so a multi-store
86
+ host points each store's ShipStation at its own integration's URL. The bare
87
+ `/shipstation` path is unchanged and belongs to the default store.
88
+ - **Ship notices are matched on ShipStation's `OrderID`** when the notice's XML
89
+ body carries it. A new fulfillment's number is its place in its order, so when
90
+ Spree deletes a parcel whose items were all moved out, the parcels after it
91
+ take new numbers; the `OrderID` (the fulfillment's id) does not change. A notice
92
+ whose `OrderID` belongs to another order is refused with `400` ("Shipment ...
93
+ does not match order id ..."), and one for a parcel Spree has deleted since
94
+ ShipStation imported it is refused with `400` instead of fulfilling the sibling
95
+ that now has its number; re-import the order from ShipStation. A notice without
96
+ an `OrderID` is matched on its number.
97
+ - **The `carrier` ShipStation sends** is recorded on the delivery when it matches
98
+ one of Spree's `tracking_carriers` (for example `UPS` or `Royal Mail`). Otherwise
99
+ Spree detects the carrier from the tracking number.
100
+ - `GET /shipstation` without the `.xml` extension answers with XML.
101
+ - An integration whose stored username or password is blank refuses every request.
102
+
103
+ ### Removed
104
+
105
+ - The gem's own payment-capture code and its use of **`auto_capture_on_dispatch`**.
106
+ Spree 6's `capture_method` replaces that setting, and Spree's upgrade task
107
+ carries it over to the store's `capture_method`.
108
+ - The `Spree::Shipment` decorator and its `exportable` and `between` scopes. The
109
+ gem no longer patches core models; `Spree::Shipstation::ExportQuery` selects what
110
+ is exported.
111
+ - The ERB admin form, the asset manifest and the `spree_extension` dependency.
112
+
113
+ ### Internal
114
+
115
+ - Specs run against the real routes and Spree's real fulfil workflow, with only
116
+ ShipStation's side simulated. The suite runs with no deprecation warnings.
117
+ - CI tests the oldest and newest Spree 6 the gem allows (6.0.0 and the latest
118
+ release) on Ruby 3.3 and 4.0, weekly as well as on pushes.
119
+
5
120
  ## 5.0.1
6
121
 
7
122
  ### Fixed
data/Gemfile CHANGED
@@ -2,15 +2,19 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "appraisal"
6
5
  gem "benchmark"
7
6
  gem "brakeman", require: false
8
7
  gem "bundler-audit", require: false
9
- gem "propshaft"
10
- gem "rails-controller-testing"
11
8
  gem "rspec-xsd"
12
- gem "spree_dev_tools"
9
+ # Constrained here because the gemspec no longer declares it; Bundler 2 would
10
+ # otherwise resolve 0.6.3, which is broken on Spree 6.
11
+ gem "spree_dev_tools", "~> 1.0"
13
12
  gem "standard"
14
13
  gem "sqlite3"
15
14
 
15
+ # CI runs a matrix of Spree versions. SPREE_VERSION pins one (e.g. "6.0.0");
16
+ # left unset, Bundler resolves the newest release the gemspec allows.
17
+ spree_version = ENV.fetch("SPREE_VERSION", "")
18
+ gem "spree", spree_version unless spree_version.empty?
19
+
16
20
  gemspec
data/README.md CHANGED
@@ -3,15 +3,25 @@
3
3
  ![CI](https://github.com/aypex-io/spree-shipstation/workflows/CI/badge.svg)
4
4
  ![Standard Rb](https://github.com/aypex-io/spree-shipstation/workflows/Standard%20Rb/badge.svg)
5
5
 
6
- The spree-shipstation integration connects your Spree stores with [ShipStation](https://www.shipstation.com), allowing ShipStation to pull shipments from your store, and when a shipment is sent, update the order with a tracking number and mark it as shipped.
6
+ The spree-shipstation integration connects your Spree stores with [ShipStation](https://www.shipstation.com), allowing ShipStation to pull the fulfillments that are ready to ship from your store, and when one is sent, update the order with a tracking number and mark it as fulfilled.
7
7
 
8
+ ## Requirements
9
+
10
+ - Spree 6 (`~> 6.0`)
11
+ - Ruby 3.3 or newer
12
+
13
+ Spree 5 stores stay on the 5.0.x releases, which live on the [`5-0-stable`](https://github.com/aypex-io/spree-shipstation/tree/5-0-stable) branch:
14
+
15
+ ```ruby
16
+ gem "spree-shipstation", "~> 5.0"
17
+ ```
8
18
 
9
19
  ## Installation
10
20
 
11
21
  1. Add this extension to your Gemfile with this line:
12
22
 
13
23
  ```ruby
14
- gem "spree-shipstation"
24
+ gem "spree-shipstation", "~> 6.0"
15
25
  ```
16
26
 
17
27
  2. Install the gem using Bundler
@@ -24,7 +34,7 @@ The extension registers itself with Spree's integration framework automatically
24
34
 
25
35
  ### Step 1: Configuring Spree
26
36
 
27
- Visit the **Integrations** section of your Spree store and configure the ShipStation integration by creating a unique username and password.
37
+ Visit the **Integrations** section of your Spree dashboard and configure the ShipStation integration (listed with the shipping integrations) by creating a unique username and password. The username is 10 to 30 characters of letters, numbers and `. _ @ + -`; the password is 20 to 60 characters and needs an uppercase letter, a number and a special character, one of `! @ # $ % ^ & * ( ) , . ? " : { } | < >`. Other characters such as `-`, `_`, `+` and `=` may appear but do not count as the special character.
28
38
 
29
39
  ### Step 2: Configuring ShipStation
30
40
 
@@ -34,41 +44,117 @@ Enter the following details:
34
44
 
35
45
  - **Username**: The username you created in Step 1.
36
46
  - **Password**: The password you created in Step 1.
37
- - **URL to custom page**: `https://your-store-domain.com/shipstation.xml`.
47
+ - **URL to custom page**: `https://shop.example.com/shipstation.xml`.
48
+
49
+ That URL belongs to your **default store**. Any other store (a multi-store setup) must use the URL that names its ShipStation integration:
50
+
51
+ ```
52
+ https://shop.example.com/shipstation/int_abc123.xml
53
+ ```
54
+
55
+ `int_abc123` stands for the integration's prefixed ID, which starts with `int_`. In a Rails console, `Spree::Integrations::Shipstation.all.map(&:prefixed_id)` lists them. The credentials are always the ones set on that integration. The `.xml` is optional on both URLs.
56
+
57
+ ### Order status
38
58
 
39
- There are five shipment states for an order (= shipment) in ShipStation. These states do not
40
- necessarily align with Spree, but you can configure ShipStation to create a mapping for your
41
- specific needs. Here's the default mapping:
59
+ Every order the gem sends has the status `ready`, so in ShipStation's Custom Store settings map `ready` to **Awaiting Shipment**. ShipStation's other statuses are never sent: the gem exports only fulfillments that can ship now, and ShipStation marks one as shipped itself once the label exists.
42
60
 
43
- ShipStation description | ShipStation status | Spree status
44
- ------------------------|--------------------|---------------
45
- Awaiting Payment | `unpaid` | `pending`
61
+ ShipStation description | ShipStation status | Sent by the gem
62
+ ------------------------|--------------------|----------------
46
63
  Awaiting Shipment | `paid` | `ready`
47
- Shipped | `shipped` | `shipped`
48
- Cancelled | `cancelled` | `cancelled`
49
- On-Hold | `on-hold` | `pending`
64
+ Anything else | — | never; only `ready` is exported
65
+
66
+ ## What ShipStation pulls
67
+
68
+ Spree 6 splits an order into fulfillments (parcels), and the gem exports one ShipStation order per fulfillment. An order with several fulfillments therefore appears once for each, every copy carrying the order's totals.
69
+
70
+ A fulfillment is exported when all of these hold:
71
+
72
+ - it belongs to the store and is still **unfulfilled**;
73
+ - its order is **placed**, so drafts and canceled orders are left out;
74
+ - its order is **paid**: paid, overcharged or partially refunded; or it owes nothing (a total of zero); or, on a store whose capture method is `on_dispatch` or `manual`, it is only authorized, or partially paid with the rest authorized (mixed tender, such as store credit plus a card) (see [Payment capture](#payment-capture));
75
+ - **none of its items is backordered**;
76
+ - it is **not a pickup**: fulfillments collected in person (pickup or pickup point delivery methods) are not shipped.
77
+
78
+ Apart from the pickup rule, these are most of the checks Spree makes again when the parcel is fulfilled, so ShipStation is rarely offered a parcel Spree would refuse. When it is fulfilled, Spree also checks that the stock location has the units on hand, and the limits under [Payment capture](#payment-capture) apply. A parcel refused then gets a `400` with Spree's reason.
79
+
80
+ ShipStation's `start_date` and `end_date` (`MM/DD/YYYY HH:MM`, UTC) limit the export to fulfillments whose own or whose order's last update falls in that window. A date the gem cannot read is ignored. Results are ordered by last update, oldest first.
81
+
82
+ `OrderID` is the fulfillment's id and `OrderNumber` its number. Fulfillments created on Spree 6 are numbered after their order, for example `R1001-F1`; fulfillments created before 6.0 keep the number they already had, so what ShipStation already holds does not change.
83
+
84
+ ## What happens on a ship notice
85
+
86
+ When a label is created, ShipStation posts to the same URL with `order_number` (the `OrderNumber` it was given), `tracking_number` and `carrier` in the query string, and the notice again as an XML body that carries the `OrderID` it was given. The gem finds that fulfillment in the store, by its `OrderID` when the body carries one and by its number otherwise, and:
87
+
88
+ - **fulfils it** through Spree's own fulfil workflow, recording the tracking number, and answers `200`;
89
+ - records the carrier when ShipStation's carrier name matches one of Spree's `tracking_carriers` (for example `UPS`, `USPS`, `FedEx` or `Royal Mail`); otherwise Spree detects it from the tracking number;
90
+ - if the fulfillment is **already fulfilled or delivered**, only records or corrects the tracking number and answers `200`. ShipStation retries, and a retry does not fulfil the parcel or publish the event again. The one exception is a tracking number that is already on a different delivery of the same fulfillment, which is refused (below);
91
+ - otherwise answers `400` with a plain-text reason and changes nothing. ShipStation shows the reason to the warehouse. The reasons include:
92
+ - `Could not find shipment with number …`, for an unknown or blank number, or an `OrderID` whose fulfillment no longer exists;
93
+ - `Shipment … does not match order id …`, when the `OrderID` belongs to a fulfillment of another order;
94
+ - `Tracking number is required`;
95
+ - `Shipment … is canceled`;
96
+ - Spree's own message when it refuses to fulfil, for example `This order is not paid for yet`, or its message about backordered units;
97
+ - `Could not capture payment: …`, when the gateway declines a capture on dispatch;
98
+ - `Tracking number has already been taken`, when a retry for a fulfilled parcel names a tracking number that already belongs to another delivery of the same fulfillment.
99
+
100
+ The gem never forces a fulfilment past Spree's paid and backordered checks, and never splits a fulfillment.
101
+
102
+ ### Why the `OrderID` comes first
103
+
104
+ A new fulfillment's number is its place in its order: `R1001-F2` is the second parcel. When every item is moved out of a parcel (with the dashboard's split or change-location actions, say), Spree deletes it, and the parcels after it move up a place and take new numbers. A fulfillment's id never changes, so the gem trusts the `OrderID` over the number. A notice for a renumbered parcel still fulfils the right one, and a notice for a parcel Spree deleted after ShipStation imported it is refused with a `400` rather than fulfilling a sibling that now has its number. Re-import the order from ShipStation to pick up its parcels as they now are.
105
+
106
+ So, in an order with several parcels, avoid moving every item out of a parcel ShipStation has already imported: that parcel is no longer valid in ShipStation.
50
107
 
51
108
  ## Configuration
52
109
 
53
- ### Payment capture on dispatch
110
+ ### Payment capture
111
+
112
+ The gem has no capture setting of its own. Capture on dispatch follows Spree 6's `capture_method`, set on the store and optionally overridden by each payment method:
113
+
114
+ - `checkout` (the default) charges as the order is placed. Authorized orders are not yet paid, so they are not exported.
115
+ - `on_dispatch` authorizes at checkout and charges when the fulfillment is fulfilled. The gem exports authorized orders, and partially paid ones whose rest is authorized, and Spree captures the payment as the ship notice is applied. If the gateway declines, ShipStation gets a `400` ("Could not capture payment: …") and nothing is kept: the fulfillment stays unfulfilled, the payment stays pending and any tracking number written during the attempt is rolled back. The next retry, or the merchant's fix to the payment, fulfils it.
116
+ - `manual` authorizes at checkout and leaves charging to your staff. The gem exports authorized and partially paid orders, and Spree lets them ship without charging them.
117
+
118
+ Two limits:
119
+
120
+ - Whether an authorized or partially paid order is exported depends on the **store's** capture method only. A store that captures at checkout but has a payment method that overrides it to `on_dispatch` never exports that method's authorized orders, and does so silently. To export them, set the store's capture method to `on_dispatch` or `manual`, bearing in mind that this changes it for every payment method that has no setting of its own. In the reverse case, a store set to `on_dispatch` with a payment method overridden to capture at checkout, an authorized order is exported and the ship notice is then refused with Spree's reason.
121
+ - A partially refunded order is exported, because the rest of it may still ship, but Spree compares the net captured amount (after refunds) with what the order was due, so the ship notice may still be refused with Spree's reason. Likewise a partially paid order ships only while the rest is authorized on payment methods that defer capture.
122
+
123
+ ### Fulfillment providers
124
+
125
+ Give the delivery methods whose parcels ShipStation ships Spree's **Manual** fulfillment provider. On a ship notice, Spree's fulfil workflow also buys a label and tells the delivery method's provider that the parcel has shipped; for Manual, both do nothing. A provider that buys labels or hands parcels to a 3PL would act a second time for a parcel ShipStation has already shipped. A declined capture on dispatch does not undo those calls either, even though everything Spree wrote is rolled back.
54
126
 
55
- The integration respects Spree's `auto_capture_on_dispatch` setting. When enabled in your Spree store, pending payments are captured automatically before a shipment is marked as shipped. If a payment capture fails, an error is returned to ShipStation (HTTP 400), preventing the shipment from being marked as shipped until the issue is resolved.
127
+ ### Telling the customer
56
128
 
57
- Payment capture happens **synchronously** within the shipnotify webhook request (inside a database transaction) so that a shipment is never marked as shipped against an uncaptured payment. ShipStation automatically retries failed webhook deliveries, and the operation is designed to be safe to repeat: a shipment that is already `shipped` is not shipped again, and only still-pending payments are captured. If your payment gateway is slow, be aware that the capture round-trip occurs in the request cycle.
129
+ Spree core does not send a shipped email by itself (as of Spree 6.0.1). When a fulfillment is fulfilled, Spree publishes the `fulfillment.fulfilled` event, and the gem asks for the customer to be notified (`notify_customer: true`). Your application decides whether, and how, to tell the customer, by subscribing to `fulfillment.fulfilled`. Without a subscriber, no shipped email goes out.
130
+
131
+ One caveat: when a capture on dispatch is declined, Spree publishes `fulfillment.fulfilled` synchronously inside the transaction that is then rolled back. A subscriber that notifies at once, rather than after the transaction commits, can tell a customer their order has shipped when the attempt was declined and nothing was kept. Spree's own fulfil endpoint behaves the same way, so if that matters to you, make your subscriber send only after the transaction has committed.
58
132
 
59
133
  ### Pagination
60
134
 
61
- The export endpoint returns up to **50 shipments per page**. ShipStation handles pagination automatically using the `page` query parameter.
135
+ The export endpoint returns up to **50 fulfillments per page**. ShipStation handles pagination automatically using the `page` query parameter; a `page` that is not a number is read as the first page.
136
+
137
+ ## Upgrading from 5.0
138
+
139
+ - Bump the gem to `~> 6.0` alongside your upgrade to Spree 6.
140
+ - The gem has no data migration of its own. Spree 6's migrations rename shipments to fulfillments, keeping their ids and numbers, and copy the integration's password into `secret_preferences` as plain text. `bin/rake spree:upgrade` (next step) encrypts it when Active Record encryption keys are configured.
141
+ - **Run `bin/rake spree:upgrade` right after `db:migrate`, before ShipStation next polls.** Spree's upgrade tasks move 5.x shipment statuses to fulfillment statuses (`ready` and `pending` become `unfulfilled`, `shipped` becomes `fulfilled`), backfill the delivery-method providers the pickup rule reads, recompute the payment and fulfillment statuses of orders whose shipments were pending, ready or shipped, encrypt stored secrets, and carry settings such as `auto_capture_on_dispatch` over to the store's `capture_method`. An order in another state, such as partly shipped or backordered, keeps its 5.x payment status until Spree next updates it. Until then the export is empty, since no fulfillment is `unfulfilled` yet. Meanwhile a parcel still marked `shipped` is treated as fulfilled: a ship notice only records its tracking.
142
+ - **ShipStation needs no changes** for the default store: same URL, same credentials, same `OrderNumber` for parcels it has already imported, same `ready` status.
143
+ - New fulfillments are numbered like `R1001-F1`. A store other than the default must use `/shipstation/<integration id>` (see Step 2): 5.x picked the store from the request's host, and Spree 6 does not, so the bare URL serves only the default store.
144
+ - Capture on dispatch now follows Spree 6's `capture_method` setting (on the store or the payment method). Spree's upgrade task carries `auto_capture_on_dispatch` over to the store's `capture_method`.
145
+ - As before, unpaid or backordered fulfillments do not appear in ShipStation until they can ship: 5.x exported only `ready` shipments.
146
+ - A ship notice for something Spree will not ship is answered with `400` and Spree's reason.
147
+ - Spree 6 core sends no shipped email on its own, so subscribe to `fulfillment.fulfilled` if your store relied on the 5.x email (see [Telling the customer](#telling-the-customer)).
62
148
 
63
149
  ## Security considerations
64
150
 
65
- - **Serve the endpoints over HTTPS.** Both `/shipstation` endpoints authenticate with HTTP Basic Auth, which transmits the configured username and password (base64-encoded) on every request. Always terminate these requests over TLS in production so the credentials are not exposed in transit. Credentials are compared in constant time to avoid timing attacks.
66
- - **Consider rate limiting.** The gem does not throttle authentication attempts. If you want brute-force protection, add it at the application or edge layer (for example, [`rack-attack`](https://github.com/rack/rack-attack)). The enforced credential length (10–30 character username, 20–60 character complex password) already makes guessing impractical.
67
- - **Response codes.** When no active ShipStation integration is configured, the endpoints respond with `404` before authentication is checked; a configured-but-unauthenticated request responds with `401`. This is intentional, but be aware it reveals whether the integration is configured.
151
+ - **Serve the endpoints over HTTPS.** Both `/shipstation` endpoints authenticate with HTTP Basic Auth, which transmits the configured username and password (base64-encoded) on every request. Always terminate these requests over TLS in production so the credentials are not exposed in transit. Credentials are compared in constant time to avoid timing attacks, and an integration whose stored username or password is blank refuses every request.
152
+ - **Consider rate limiting.** The gem does not throttle authentication attempts. If you want brute-force protection, add it at the application or edge layer (for example, [`rack-attack`](https://github.com/rack/rack-attack) or Rails' `rate_limit`). The enforced credential length (10–30 character username, 20–60 character complex password) already makes guessing impractical.
153
+ - **Response codes.** When no active ShipStation integration is configured, the endpoints respond with `404` before authentication is checked; a configured-but-unauthenticated request responds with `401`. This is intentional, but be aware it reveals whether the integration is configured. An integration that is switched off is the same as none.
68
154
 
69
155
  ## Performance
70
156
 
71
- The export query eager-loads its association graph to avoid N+1 queries. The `exportable` scope filters shipments by `state` and orders them by `updated_at`, joining and filtering orders by their `updated_at`. The gem ships no migrations of its own; on large stores, ensure the relevant Spree core columns (`spree_shipments.state`, `spree_shipments.updated_at`, `spree_orders.updated_at`) are adequately indexed in your application's database.
157
+ The export query eager-loads its association graph to avoid N+1 queries. It selects unfulfilled fulfillments, joins their orders and filters by payment status, and orders and windows by `updated_at`. The gem ships no migrations of its own. Spree 6's schema indexes `spree_fulfillments.status` and `spree_orders.payment_status`, but not `spree_fulfillments.updated_at` or `spree_orders.updated_at`; on large stores, consider indexing those two in your application's database.
72
158
 
73
159
  ## Usage
74
160
 
@@ -76,9 +162,10 @@ There's nothing you need to do. Once properly configured, the integration just w
76
162
 
77
163
  ### Compatibility
78
164
 
79
- This extension works with the following Spree versions:
80
- - 5.x
81
-
165
+ spree-shipstation | Spree | Ruby
166
+ ------------------|-------|------
167
+ 6.0.x | 6.x (`~> 6.0`) | 3.3 or newer
168
+ 5.0.x, on [`5-0-stable`](https://github.com/aypex-io/spree-shipstation/tree/5-0-stable) | 5.x | 3.1 or newer
82
169
 
83
170
  ### Testing
84
171
 
@@ -94,6 +181,13 @@ To run the tests use:
94
181
  bundle exec rake
95
182
  ```
96
183
 
184
+ CI runs the suite against Spree 6.0.0 and the newest 6.x release. To test against a specific Spree version locally:
185
+
186
+ ```shell
187
+ SPREE_VERSION=6.0.0 bundle update spree
188
+ bundle exec rake
189
+ ```
190
+
97
191
  ### Code Formatting
98
192
 
99
193
  To check your code formatting with [Standard Rb](https://github.com/testdouble/standard) run:
@@ -110,10 +204,7 @@ bundle exec standardrb --fix
110
204
 
111
205
  ## Releasing
112
206
 
113
- ```bash
114
- bundle exec gem bump -p -t
115
- bundle exec gem release
116
- ```
207
+ Bump `Spree::Shipstation::VERSION` in `lib/spree/shipstation/version.rb`, add the release to the changelog, and merge. Pushing a `v*` tag publishes the gem to RubyGems through the Release workflow.
117
208
 
118
209
  ## License
119
210
 
data/Rakefile CHANGED
@@ -23,19 +23,18 @@ task :test_app do
23
23
  # literal `require ENV['LIB_NAME']` and templates the same string into the
24
24
  # generated spec/dummy/config/application.rb.
25
25
  ENV["LIB_NAME"] = "spree/shipstation"
26
- Rake::Task["extension:test_app"].execute(
27
- install_admin: true
28
- )
26
+ # Spree 6's extension:test_app takes no install_admin option.
27
+ Rake::Task["extension:test_app"].execute
29
28
  end
30
29
 
31
30
  namespace :dummy do
32
31
  # Spree's test_app task runs `db:migrate` in the generator's own process with
33
- # output redirected to /dev/null and its exit status ignored. On the heavier
34
- # Spree 5.3+/5.4 setup path (admin install + Tailwind/asset build) that step
35
- # can be interrupted mid schema-dump, leaving a TRUNCATED db/schema.rb. Rails
36
- # then loads that broken dump on the next migrate and fails, so the dummy
37
- # database ends up empty and the whole suite errors with
38
- # "Could not find table 'spree_*'".
32
+ # output redirected to /dev/null and its exit status ignored. If a migration
33
+ # fails partway (for example, Spree's preferences conversion eager loads the
34
+ # whole app, so any class that fails to load stops it), the dummy database is
35
+ # left half-built and the whole suite errors with weird failures or
36
+ # "Could not find table 'spree_*'", with no hint of the cause. Rails then
37
+ # loads a possibly truncated db/schema.rb dump on the next migrate too.
39
38
  #
40
39
  # Make setup deterministic: discard any (possibly corrupt) schema dump and
41
40
  # rebuild the database from the migration files, in a fresh process and
@@ -2,30 +2,51 @@
2
2
 
3
3
  module Spree
4
4
  ##
5
- # HTTP endpoints ShipStation polls for ready shipments and posts ship notices to.
5
+ # HTTP endpoints ShipStation polls for ready parcels and posts ship notices to.
6
6
  #
7
- class ShipstationController < Spree::BaseController
8
- include Spree::IntegrationsHelper
7
+ # Every request is answered for one ShipStation integration, and so for one
8
+ # store. The bare +/shipstation+ path uses the default store's integration;
9
+ # +/shipstation/:integration_id+ uses the integration with that prefixed id.
10
+ # Requests are authenticated with HTTP Basic against the integration's own
11
+ # credentials, after the integration is found: with no active integration the
12
+ # answer is 404, whoever asks.
13
+ #
14
+ # This is an +ActionController::Base+ rather than an API controller because
15
+ # the export is a Builder template, which +ActionController::API+ renders as
16
+ # an empty body.
17
+ #
18
+ class ShipstationController < ActionController::Base
19
+ include Spree::Core::ControllerHelpers::Store
9
20
  include Pagy::Method
10
21
 
11
- protect_from_forgery with: :null_session, only: :shipnotify
22
+ PER_PAGE = 50
23
+ private_constant :PER_PAGE
24
+
25
+ # A ship notice body is a few kilobytes. A larger one is not parsed.
26
+ MAX_NOTICE_BYTES = 64.kilobytes
27
+ private_constant :MAX_NOTICE_BYTES
28
+
29
+ # Forgery protection is the default for this controller, so an action added
30
+ # later is protected until it says otherwise. ShipStation's two actions opt
31
+ # out: HTTP Basic is their authentication and there is no session to forge.
32
+ protect_from_forgery with: :exception
33
+ skip_forgery_protection only: %i[export shipnotify]
12
34
 
13
- before_action :ensure_active_integration
35
+ before_action :find_integration
14
36
  before_action :authenticate_shipstation
15
37
 
16
38
  ##
17
- # XML export of ready shipments for ShipStation to pull.
39
+ # XML export of the store's ready parcels for ShipStation to pull.
18
40
  #
19
41
  # @return [void]
20
42
  #
21
43
  def export
22
- @pagy, @shipments = pagy(
23
- current_store.shipments
24
- .exportable
25
- .between(date_param(:start_date), date_param(:end_date)),
26
- page: params[:page],
27
- items: 50
44
+ query = Shipstation::ExportQuery.new(
45
+ store: @integration.store,
46
+ from: date_param(:start_date),
47
+ to: date_param(:end_date)
28
48
  )
49
+ @pagy, @fulfillments = pagy(query.call, limit: PER_PAGE)
29
50
 
30
51
  respond_to do |format|
31
52
  format.xml { render layout: false }
@@ -33,12 +54,15 @@ module Spree
33
54
  end
34
55
 
35
56
  ##
36
- # Applies a ship notification from ShipStation and marks the shipment shipped.
57
+ # Applies a ship notice from ShipStation and fulfils the parcel. The notice
58
+ # travels in the query string. Of the XML body ShipStation also sends, only
59
+ # the +OrderID+ is read.
37
60
  #
38
61
  # @return [void]
39
62
  #
40
63
  def shipnotify
41
- Shipstation::ShipmentNotice.from_payload(params.permit(:order_number, :tracking_number).to_h, store: current_store).apply
64
+ payload = notice_params.merge(order_id: notice_order_id)
65
+ Shipstation::ShipmentNotice.from_payload(payload, store: @integration.store).apply
42
66
  head :ok
43
67
  rescue Shipstation::Error => e
44
68
  Rails.logger.error("ShipStation Notification Error: #{e.message}")
@@ -47,10 +71,52 @@ module Spree
47
71
 
48
72
  private
49
73
 
50
- def ensure_active_integration
51
- head :not_found unless store_integration("shipstation")&.active?
74
+ # Only the notice's own fields. Sliced first, so the integration id, the
75
+ # format and ShipStation's other query params are never "unpermitted", which
76
+ # would raise in a host app that treats them so.
77
+ def notice_params
78
+ params.slice(:order_number, :tracking_number, :carrier)
79
+ .permit(:order_number, :tracking_number, :carrier).to_h
80
+ end
81
+
82
+ # The +OrderID+ from the notice's XML body: the fulfillment's id, as the
83
+ # export sent it. Parsed strictly and with Nokogiri's defaults otherwise, so
84
+ # no network access and no entity expansion. A body that is empty, too
85
+ # large or not well-formed, or an +OrderID+ that is missing or not a whole
86
+ # number, gives nil, and the notice is matched on its number alone.
87
+ def notice_order_id
88
+ body = request.raw_post
89
+ return if body.blank? || body.bytesize > MAX_NOTICE_BYTES
90
+
91
+ text = Nokogiri::XML(body, &:strict).at_xpath("/ShipNotice/OrderID")&.text.to_s.strip
92
+ text.to_i if text.match?(/\A\d+\z/)
93
+ rescue Nokogiri::XML::SyntaxError
94
+ nil
95
+ end
96
+
97
+ # Finds the active integration this request is for and makes its store the
98
+ # current one. Anything else is a 404, before the caller is asked who they are.
99
+ def find_integration
100
+ @integration = resolve_integration
101
+ return head :not_found unless @integration
102
+
103
+ Spree::Current.store = @integration.store
104
+ end
105
+
106
+ # Only the path names an integration. A query-string +integration_id+ on the
107
+ # bare URL is ignored, so that URL always belongs to the default store.
108
+ def resolve_integration
109
+ integration_id = request.path_parameters[:integration_id]
110
+
111
+ if integration_id.present?
112
+ Spree::Integrations::Shipstation.active.find_by_prefix_id(integration_id)
113
+ else
114
+ current_store.integrations.active.find_by(type: Spree::Integrations::Shipstation.sti_name)
115
+ end
52
116
  end
53
117
 
118
+ # ShipStation sends dates as +MM/DD/YYYY HH:MM+ in UTC. One it cannot read
119
+ # is dropped, which widens the window rather than failing the pull.
54
120
  def date_param(name)
55
121
  return if params[name].blank?
56
122
 
@@ -61,9 +127,13 @@ module Spree
61
127
 
62
128
  def authenticate_shipstation
63
129
  authenticate_or_request_with_http_basic do |username, password|
64
- integration = store_integration("shipstation")
65
- ActiveSupport::SecurityUtils.secure_compare(username.to_s, integration.preferred_username.to_s) &&
66
- ActiveSupport::SecurityUtils.secure_compare(password.to_s, integration.preferred_password.to_s)
130
+ # Credentials the integration does not hold would match anything blank.
131
+ next false if @integration.preferred_username.blank? || @integration.preferred_password.blank?
132
+
133
+ # A non-short-circuiting & so both fields are always compared, and the
134
+ # time taken does not reveal whether the username matched.
135
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, @integration.preferred_username.to_s) &
136
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, @integration.preferred_password.to_s)
67
137
  end
68
138
  end
69
139
  end
@@ -39,7 +39,7 @@ module Spree
39
39
  return unless address
40
40
 
41
41
  xml.tag!(tag_name) do
42
- xml.Name name_from(address)
42
+ xml.Name address.full_name
43
43
  xml.Company address.company
44
44
 
45
45
  if include_street
@@ -47,22 +47,16 @@ module Spree
47
47
  xml.Address2 address.address2
48
48
  xml.City address.city
49
49
  xml.State state_from(address)
50
- xml.PostalCode address.zipcode
51
- xml.Country address.country&.iso
50
+ xml.PostalCode address.postal_code
51
+ xml.Country address.country_code
52
52
  end
53
53
 
54
54
  xml.Phone address.phone
55
55
  end
56
56
  end
57
57
 
58
- def name_from(address)
59
- return address.name if address.respond_to?(:name)
60
-
61
- address.try(:full_name)
62
- end
63
-
64
58
  def state_from(address)
65
- address.state&.abbr || address.state_name
59
+ address.state_code.presence || address.state_name
66
60
  end
67
61
  end
68
62
  end