spree-shipstation 5.0.1 → 6.0.1
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 +124 -0
- data/Gemfile +8 -4
- data/README.md +119 -28
- data/Rakefile +8 -9
- data/app/controllers/spree/shipstation_controller.rb +89 -19
- data/app/helpers/spree/shipstation/export_helper.rb +4 -10
- data/app/models/spree/integrations/shipstation.rb +50 -29
- data/app/presenters/spree/shipstation/export/item_presenter.rb +20 -15
- data/app/presenters/spree/shipstation/export/order_presenter.rb +33 -19
- data/app/views/spree/shipstation/export.xml.builder +11 -7
- data/config/initializers/spree.rb +3 -1
- data/config/locales/en.yml +4 -5
- data/config/routes.rb +12 -2
- data/lib/spree/shipstation/engine.rb +2 -14
- data/lib/spree/shipstation/errors.rb +36 -4
- data/lib/spree/shipstation/export_query.rb +136 -0
- data/lib/spree/shipstation/shipment_notice.rb +162 -41
- data/lib/spree/shipstation/version.rb +3 -2
- data/lib/spree/shipstation.rb +1 -1
- data/spree-shipstation.gemspec +8 -7
- metadata +10 -42
- data/.github/stale.yml +0 -17
- data/.github/workflows/lint.yml +0 -24
- data/.github/workflows/release.yml +0 -46
- data/.github/workflows/security.yml +0 -41
- data/.github/workflows/test.yml +0 -32
- data/Appraisals +0 -20
- data/CLAUDE.md +0 -98
- data/app/assets/config/spree_shipstation_manifest.js +0 -1
- data/app/models/spree/shipment_decorator.rb +0 -32
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51f07d4fcced52aecb29efb53060faf34b694ca79f5aad40354e863d5af36a21
|
|
4
|
+
data.tar.gz: 751d4675491fd6ad48f5e06c324046b5de29970729e44b001d9af3fa3bd54464
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78561c0f83a49b89661cbf09f0f7a514f479ac547e10ddb94ab99138e385c528b03d7dfbe50651c729b5438363b588fb65c67c11858584177ed6e08b2e949ed2
|
|
7
|
+
data.tar.gz: 613780285783c9dbc119f49e8dddb703e016eaacd0cb4eaf2e19ecf3bb87b4d9f6e11f0c5b9956485c1a6ffc78cae109e3c63a5a70ad1390c69741b4f2eb879b
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,130 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 6.0.1
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The export no longer fails with a 500 (`undefined method 'attachment_path'`)
|
|
10
|
+
for an order whose items have an image.
|
|
11
|
+
- `ImageUrl` is now an absolute URL on the host ShipStation polls, so ShipStation
|
|
12
|
+
can fetch item images (#21).
|
|
13
|
+
|
|
14
|
+
## 6.0.0
|
|
15
|
+
|
|
16
|
+
Spree 6 support. The major version tracks Spree's, so `spree-shipstation` 6.x
|
|
17
|
+
supports Spree 6, and Spree 5 stays on the 5.0.x releases (the `5-0-stable`
|
|
18
|
+
branch).
|
|
19
|
+
|
|
20
|
+
**Nothing ShipStation or the merchant has configured has to change** for the
|
|
21
|
+
default store. The `/shipstation` URL, the credentials, the XML, the `ready`
|
|
22
|
+
status and the `OrderNumber` of every fulfillment ShipStation already holds stay
|
|
23
|
+
as they were. A store other than the default moves to its integration's URL
|
|
24
|
+
(below).
|
|
25
|
+
|
|
26
|
+
**Run `bin/rake spree:upgrade` right after `db:migrate`, before ShipStation next
|
|
27
|
+
polls.** Spree's upgrade tasks move 5.x shipment statuses to fulfillment statuses
|
|
28
|
+
(`ready` and `pending` become `unfulfilled`, `shipped` becomes `fulfilled`),
|
|
29
|
+
backfill the delivery-method providers the pickup rule reads, recompute the
|
|
30
|
+
statuses of orders whose shipments were pending, ready or shipped, encrypt stored
|
|
31
|
+
secrets, and carry `auto_capture_on_dispatch` over to the store's
|
|
32
|
+
`capture_method`. Until then the export is empty. See "Upgrading
|
|
33
|
+
from 5.0" in the README.
|
|
34
|
+
|
|
35
|
+
**Requirements:** Spree `~> 6.0`, Ruby 3.3 or newer, pagy 43.6 or newer. The 5.x
|
|
36
|
+
releases do not install on Spree 6.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Orders are exported per fulfillment**, since Spree 6 replaced shipments with
|
|
41
|
+
fulfillments. `OrderID` is the fulfillment's id and `OrderNumber` its number:
|
|
42
|
+
the number a fulfillment already had before Spree 6, or `R1001-F1` style
|
|
43
|
+
(order number, `-F`, position) for new ones. Totals stay order-level, as before.
|
|
44
|
+
- **Only fulfillments Spree will let ship are exported**: placed, still
|
|
45
|
+
unfulfilled, paid, with nothing backordered, and not a pickup. "Paid" means
|
|
46
|
+
paid, overcharged or partially refunded; or nothing owed; or, on a store whose
|
|
47
|
+
capture method is `on_dispatch` or `manual`, authorized, or partially paid with
|
|
48
|
+
the rest authorized (mixed tender, such as store credit plus a card). As before,
|
|
49
|
+
unpaid or backordered fulfillments do not appear in ShipStation until they can
|
|
50
|
+
ship: 5.x exported only `ready` shipments. Results are ordered by last update,
|
|
51
|
+
oldest first.
|
|
52
|
+
- **Ship notices fulfil through Spree 6's fulfil workflow.** When Spree refuses
|
|
53
|
+
(an unpaid order, backordered items, a canceled fulfillment) the notice is
|
|
54
|
+
answered with `400` and Spree's reason, and nothing is recorded. It is never
|
|
55
|
+
answered `200` for a fulfillment Spree did not ship.
|
|
56
|
+
- **Ship notices are retry-safe.** A notice for a fulfillment that is already
|
|
57
|
+
fulfilled or delivered only records or corrects the tracking number: it does
|
|
58
|
+
not fulfil the parcel or publish the event again. A retry that names a tracking
|
|
59
|
+
number already on another delivery of the same fulfillment is refused with
|
|
60
|
+
`400` ("Tracking number has already been taken"). Concurrent retries are
|
|
61
|
+
serialized under the order's row lock. A parcel still marked `shipped` from 5.x,
|
|
62
|
+
before Spree's upgrade task renames the status, is treated the same way.
|
|
63
|
+
- **A notice without an order number is refused** with `400`. New fulfillments
|
|
64
|
+
store no number, so a blank one would otherwise match any of them.
|
|
65
|
+
- **Capture on dispatch follows Spree 6's `capture_method`**, set on the store or
|
|
66
|
+
overridden per payment method. A declined capture answers `400` ("Could not
|
|
67
|
+
capture payment: ...") and keeps nothing: the fulfillment stays unfulfilled,
|
|
68
|
+
the payment stays pending and the tracking written during the attempt is rolled
|
|
69
|
+
back, so the retry starts from the same place.
|
|
70
|
+
- **Shipped notifications are the host app's to send.** Spree 6 core sends no
|
|
71
|
+
shipped email itself. The fulfil workflow publishes `fulfillment.fulfilled` with
|
|
72
|
+
`notify_customer` set, and your application's subscriber decides whether, and
|
|
73
|
+
how, to tell the customer. On a declined capture core publishes that event
|
|
74
|
+
inside the transaction that is then rolled back, so a subscriber that notifies
|
|
75
|
+
at once can announce a shipment that did not happen (Spree's own fulfil endpoint
|
|
76
|
+
behaves the same).
|
|
77
|
+
- `ImageUrl` uses the media's still image, so a video thumbnail exports its
|
|
78
|
+
poster. It is still a root-relative path.
|
|
79
|
+
- The export returns **50 fulfillments per page**, as documented. (pagy 43, which
|
|
80
|
+
Spree 6 requires, ignores the option the gem used to set this and would have
|
|
81
|
+
given 25.) A `page` that is not a number is read as page 1.
|
|
82
|
+
- The integration appears in Spree 6's dashboard under the shipping group, with a
|
|
83
|
+
description, logo and link to the README. Its form is generated from the
|
|
84
|
+
username and password preferences. Spree 6's migration copies the password into
|
|
85
|
+
`secret_preferences` as plain text, and `bin/rake spree:upgrade` encrypts it
|
|
86
|
+
when Active Record encryption keys are configured.
|
|
87
|
+
- Both endpoints now run on a plain `ActionController::Base` controller, since
|
|
88
|
+
Spree 6 removed the base controller and helper the 5.x one used.
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
- **`/shipstation/<integration id>`** (`GET` and `POST`, with or without `.xml`)
|
|
93
|
+
serves the store of the active ShipStation integration with that `int_` prefixed
|
|
94
|
+
id. Spree 6 no longer picks the store from the request's host, so a multi-store
|
|
95
|
+
host points each store's ShipStation at its own integration's URL. The bare
|
|
96
|
+
`/shipstation` path is unchanged and belongs to the default store.
|
|
97
|
+
- **Ship notices are matched on ShipStation's `OrderID`** when the notice's XML
|
|
98
|
+
body carries it. A new fulfillment's number is its place in its order, so when
|
|
99
|
+
Spree deletes a parcel whose items were all moved out, the parcels after it
|
|
100
|
+
take new numbers; the `OrderID` (the fulfillment's id) does not change. A notice
|
|
101
|
+
whose `OrderID` belongs to another order is refused with `400` ("Shipment ...
|
|
102
|
+
does not match order id ..."), and one for a parcel Spree has deleted since
|
|
103
|
+
ShipStation imported it is refused with `400` instead of fulfilling the sibling
|
|
104
|
+
that now has its number; re-import the order from ShipStation. A notice without
|
|
105
|
+
an `OrderID` is matched on its number.
|
|
106
|
+
- **The `carrier` ShipStation sends** is recorded on the delivery when it matches
|
|
107
|
+
one of Spree's `tracking_carriers` (for example `UPS` or `Royal Mail`). Otherwise
|
|
108
|
+
Spree detects the carrier from the tracking number.
|
|
109
|
+
- `GET /shipstation` without the `.xml` extension answers with XML.
|
|
110
|
+
- An integration whose stored username or password is blank refuses every request.
|
|
111
|
+
|
|
112
|
+
### Removed
|
|
113
|
+
|
|
114
|
+
- The gem's own payment-capture code and its use of **`auto_capture_on_dispatch`**.
|
|
115
|
+
Spree 6's `capture_method` replaces that setting, and Spree's upgrade task
|
|
116
|
+
carries it over to the store's `capture_method`.
|
|
117
|
+
- The `Spree::Shipment` decorator and its `exportable` and `between` scopes. The
|
|
118
|
+
gem no longer patches core models; `Spree::Shipstation::ExportQuery` selects what
|
|
119
|
+
is exported.
|
|
120
|
+
- The ERB admin form, the asset manifest and the `spree_extension` dependency.
|
|
121
|
+
|
|
122
|
+
### Internal
|
|
123
|
+
|
|
124
|
+
- Specs run against the real routes and Spree's real fulfil workflow, with only
|
|
125
|
+
ShipStation's side simulated. The suite runs with no deprecation warnings.
|
|
126
|
+
- CI tests the oldest and newest Spree 6 the gem allows (6.0.0 and the latest
|
|
127
|
+
release) on Ruby 3.3 and 4.0, weekly as well as on pushes.
|
|
128
|
+
|
|
5
129
|
## 5.0.1
|
|
6
130
|
|
|
7
131
|
### 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
|
-
|
|
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
|

|
|
4
4
|

|
|
5
5
|
|
|
6
|
-
The spree-shipstation integration connects your Spree stores with [ShipStation](https://www.shipstation.com), allowing ShipStation to pull
|
|
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
|
|
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://
|
|
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
|
-
|
|
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 |
|
|
44
|
-
|
|
45
|
-
Awaiting Payment | `unpaid` | `pending`
|
|
61
|
+
ShipStation description | ShipStation status | Sent by the gem
|
|
62
|
+
------------------------|--------------------|----------------
|
|
46
63
|
Awaiting Shipment | `paid` | `ready`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
127
|
+
### Telling the customer
|
|
56
128
|
|
|
57
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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.
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
5
|
+
# HTTP endpoints ShipStation polls for ready parcels and posts ship notices to.
|
|
6
6
|
#
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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 :
|
|
35
|
+
before_action :find_integration
|
|
14
36
|
before_action :authenticate_shipstation
|
|
15
37
|
|
|
16
38
|
##
|
|
17
|
-
# XML export of ready
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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.
|
|
51
|
-
xml.Country address.
|
|
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.
|
|
59
|
+
address.state_code.presence || address.state_name
|
|
66
60
|
end
|
|
67
61
|
end
|
|
68
62
|
end
|