@spree/docs 0.1.230 → 0.1.232
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.
- package/dist/api-reference/store.yaml +28 -0
- package/dist/developer/agentic/agent-skills.md +1 -1
- package/dist/developer/cli/quickstart.md +2 -2
- package/dist/developer/core-concepts/addresses.md +13 -0
- package/dist/developer/core-concepts/calculators.md +6 -1
- package/dist/developer/core-concepts/carts.md +19 -1
- package/dist/developer/core-concepts/catalogs.md +51 -2
- package/dist/developer/core-concepts/channels.md +20 -1
- package/dist/developer/core-concepts/commissions.md +70 -1
- package/dist/developer/core-concepts/companies.md +74 -0
- package/dist/developer/core-concepts/customers.md +17 -5
- package/dist/developer/core-concepts/data-privacy.md +20 -7
- package/dist/developer/core-concepts/delivery-setup.md +57 -0
- package/dist/developer/core-concepts/discounts.md +7 -0
- package/dist/developer/core-concepts/events.md +13 -7
- package/dist/developer/core-concepts/fees.md +20 -0
- package/dist/developer/core-concepts/fulfillments.md +3 -3
- package/dist/developer/core-concepts/imports-exports.md +21 -5
- package/dist/developer/core-concepts/inventory.md +73 -1
- package/dist/developer/core-concepts/markets.md +5 -6
- package/dist/developer/core-concepts/media.md +66 -1
- package/dist/developer/core-concepts/metafields.md +1 -1
- package/dist/developer/core-concepts/order-totals.md +18 -1
- package/dist/developer/core-concepts/orders.md +2 -1
- package/dist/developer/core-concepts/payments.md +100 -20
- package/dist/developer/core-concepts/pricing.md +13 -8
- package/dist/developer/core-concepts/products.md +52 -1
- package/dist/developer/core-concepts/reporting.md +5 -5
- package/dist/developer/core-concepts/returns-exchanges-claims.md +41 -2
- package/dist/developer/core-concepts/search-filtering.md +1 -2
- package/dist/developer/core-concepts/sellers.md +109 -2
- package/dist/developer/core-concepts/slugs.md +1 -1
- package/dist/developer/core-concepts/staff-roles.md +23 -3
- package/dist/developer/core-concepts/store-credits-gift-cards.md +60 -23
- package/dist/developer/core-concepts/stores.md +21 -0
- package/dist/developer/core-concepts/taxes.md +43 -0
- package/dist/developer/core-concepts/translations.md +9 -0
- package/dist/developer/core-concepts/webhooks.md +45 -3
- package/dist/developer/create-spree-app/quickstart.md +10 -10
- package/dist/developer/dashboard/deployment.md +4 -4
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +1 -1
- package/dist/developer/deployment/docker.md +2 -2
- package/dist/developer/how-to/custom-promotion.md +33 -13
- package/dist/developer/multi-tenant/quickstart.md +6 -6
- package/dist/developer/storefront/nextjs/testing.md +2 -2
- package/package.json +1 -1
|
@@ -19,7 +19,7 @@ A person has the right to a copy of their data and the right to have it
|
|
|
19
19
|
erased. Both arrive as a `Spree::DataRequest`, which records that the request
|
|
20
20
|
was made and how it was answered.
|
|
21
21
|
|
|
22
|
-
```
|
|
22
|
+
```http
|
|
23
23
|
POST /api/v3/store/customers/me/data_requests { "kind": "access" }
|
|
24
24
|
POST /api/v3/store/customers/me/data_requests { "kind": "erasure", "current_password": "..." }
|
|
25
25
|
```
|
|
@@ -37,7 +37,7 @@ Most requests never reach the storefront. They arrive as email to the
|
|
|
37
37
|
merchant, often from someone who can no longer sign in, so the same two
|
|
38
38
|
actions exist in the admin:
|
|
39
39
|
|
|
40
|
-
```
|
|
40
|
+
```http
|
|
41
41
|
GET /api/v3/admin/customers/:id/export
|
|
42
42
|
POST /api/v3/admin/customers/:id/anonymize
|
|
43
43
|
```
|
|
@@ -69,7 +69,7 @@ A `customer.anonymized` event is published when it completes.
|
|
|
69
69
|
Spree exports what Spree knows. If your application holds data of its own — a
|
|
70
70
|
loyalty ledger, support history — add it through the hook:
|
|
71
71
|
|
|
72
|
-
```ruby
|
|
72
|
+
```ruby server/config/initializers/spree.rb
|
|
73
73
|
Spree.hooks.register('data_requests.fulfill.extend_payload', 'MyStore::LoyaltyExport')
|
|
74
74
|
|
|
75
75
|
class MyStore::LoyaltyExport
|
|
@@ -85,7 +85,7 @@ drop what Spree contributes.
|
|
|
85
85
|
You can also veto an erasure — an open dispute, a fraud investigation, a legal
|
|
86
86
|
hold:
|
|
87
87
|
|
|
88
|
-
```ruby
|
|
88
|
+
```ruby server/config/initializers/spree.rb
|
|
89
89
|
Spree.hooks.register('customers.anonymize.validate', 'MyStore::LegalHold')
|
|
90
90
|
|
|
91
91
|
class MyStore::LegalHold
|
|
@@ -121,7 +121,7 @@ The Omnibus Directive requires that an announced price reduction shows the
|
|
|
121
121
|
lowest price of the previous 30 days. Spree records every base price change in
|
|
122
122
|
`Spree::PriceHistory` and exposes the lowest prior price on request:
|
|
123
123
|
|
|
124
|
-
```
|
|
124
|
+
```http
|
|
125
125
|
GET /api/v3/store/products/:id?expand=prior_price
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -129,16 +129,22 @@ Recording is on by default and controlled per store with
|
|
|
129
129
|
`track_price_history`, since a non-EU store has no use for it. Retention
|
|
130
130
|
defaults to 30 days via `price_history_retention_days`; prune with:
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
|
|
133
|
+
```bash Spree CLI (Docker)
|
|
134
|
+
spree rake spree:price_history:prune
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```bash Without Spree CLI
|
|
133
138
|
bundle exec rake spree:price_history:prune
|
|
134
139
|
```
|
|
135
140
|
|
|
141
|
+
|
|
136
142
|
## Right of withdrawal
|
|
137
143
|
|
|
138
144
|
EU buyers may withdraw from a distance sale within 14 days of receiving the
|
|
139
145
|
goods. Orders expose the deadline:
|
|
140
146
|
|
|
141
|
-
```json
|
|
147
|
+
```json Response
|
|
142
148
|
{ "withdrawal_period_ends_at": "2026-09-20T10:30:00Z", "within_withdrawal_period": true }
|
|
143
149
|
```
|
|
144
150
|
|
|
@@ -150,3 +156,10 @@ goodwill, while withdrawal is a statutory right running from delivery.
|
|
|
150
156
|
The deadline is computed on read from the order's latest delivery, falling back
|
|
151
157
|
to completion while nothing has been delivered. A stored copy would be wrong
|
|
152
158
|
the moment a parcel arrived.
|
|
159
|
+
|
|
160
|
+
## Related
|
|
161
|
+
|
|
162
|
+
- [Pricing](pricing.md) — price history and the `prior_price` an Omnibus display needs
|
|
163
|
+
- [Customers](customers.md) — the account an export or erasure acts on
|
|
164
|
+
- [Markets](markets.md) — where `withdrawal_period_days` is set, since the right is regional law
|
|
165
|
+
- [Events](events.md) — subscribing to erasure and data-request activity
|
|
@@ -65,6 +65,7 @@ That is how a store with a refrigerated warehouse keeps chilled goods from
|
|
|
65
65
|
being picked at a dry one: order routing only allocates from locations the
|
|
66
66
|
profile covers.
|
|
67
67
|
|
|
68
|
+
|
|
68
69
|
```typescript Admin SDK
|
|
69
70
|
// What profiles the store has
|
|
70
71
|
const profiles = await adminClient.deliveryProfiles.list()
|
|
@@ -75,6 +76,19 @@ await adminClient.products.update('prod_xxx', {
|
|
|
75
76
|
})
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
```bash cURL
|
|
80
|
+
# What profiles the store has
|
|
81
|
+
curl 'https://api.mystore.com/api/v3/admin/delivery_profiles' \
|
|
82
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
83
|
+
|
|
84
|
+
# Ship a product as a download
|
|
85
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx' \
|
|
86
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
87
|
+
-H 'Content-Type: application/json' \
|
|
88
|
+
-d '{ "delivery_profile_id": "dp_digital" }'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
|
|
78
92
|
> **NOTE:** Profiles are a template at creation time, not a live binding: changing a
|
|
79
93
|
> profile does not rewrite products already assigned to it. Reassign a product
|
|
80
94
|
> explicitly when it should ship differently.
|
|
@@ -85,6 +99,7 @@ A **delivery method** is what the customer picks — "Standard", "Express", "Col
|
|
|
85
99
|
|
|
86
100
|
Zones match on country and state, and also on postal code prefixes and ranges, so you can offer same-day delivery to a handful of city postcodes without listing every one:
|
|
87
101
|
|
|
102
|
+
|
|
88
103
|
```typescript Admin SDK
|
|
89
104
|
await adminClient.deliveryZones.create({
|
|
90
105
|
name: 'London same-day',
|
|
@@ -95,6 +110,19 @@ await adminClient.deliveryZones.create({
|
|
|
95
110
|
})
|
|
96
111
|
```
|
|
97
112
|
|
|
113
|
+
```bash cURL
|
|
114
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/delivery_zones' \
|
|
115
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
116
|
+
-H 'Content-Type: application/json' \
|
|
117
|
+
-d '{
|
|
118
|
+
"name": "London same-day",
|
|
119
|
+
"members": [
|
|
120
|
+
{ "member_type": "postal_code", "country_code": "GB", "postal_code_prefix": "SW1" }
|
|
121
|
+
]
|
|
122
|
+
}'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
|
|
98
126
|
A zone member is either a whole country, a state, or a postal code prefix or range.
|
|
99
127
|
|
|
100
128
|
> **NOTE:** Delivery zones cover delivery only. Tax is handled separately — see [Taxes](taxes.md).
|
|
@@ -129,6 +157,7 @@ Package types are that vocabulary: the box parcels ship in, and for wholesale,
|
|
|
129
157
|
the cartons products are packed into and the pallets or containers an order
|
|
130
158
|
leaves on.
|
|
131
159
|
|
|
160
|
+
|
|
132
161
|
```typescript Admin SDK
|
|
133
162
|
await adminClient.packageTypes.create({
|
|
134
163
|
name: 'Standard mailer',
|
|
@@ -139,6 +168,20 @@ await adminClient.packageTypes.create({
|
|
|
139
168
|
})
|
|
140
169
|
```
|
|
141
170
|
|
|
171
|
+
```bash cURL
|
|
172
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/package_types' \
|
|
173
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
174
|
+
-H 'Content-Type: application/json' \
|
|
175
|
+
-d '{
|
|
176
|
+
"name": "Standard mailer",
|
|
177
|
+
"kind": "box",
|
|
178
|
+
"length": 30, "width": 20, "height": 15, "dimensions_unit": "cm",
|
|
179
|
+
"weight": 0.4, "weight_unit": "kg",
|
|
180
|
+
"default": true
|
|
181
|
+
}'
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
|
|
142
185
|
One row is marked `default`, and that is the box quotes are built on. Marking
|
|
143
186
|
another one default demotes the first, so there is never more than one.
|
|
144
187
|
|
|
@@ -162,6 +205,7 @@ falling back to the marketplace's when a seller has not recorded one. Sellers
|
|
|
162
205
|
manage theirs through the Seller API, and can also pack products into the
|
|
163
206
|
marketplace's cartons — those they read but cannot change.
|
|
164
207
|
|
|
208
|
+
|
|
165
209
|
```typescript Seller SDK
|
|
166
210
|
// Their own packaging — what the seller panel's settings page lists.
|
|
167
211
|
const { data: mine } = await sellerClient.packageTypes.list({ owner: 'mine' })
|
|
@@ -176,6 +220,19 @@ available.forEach((row) => {
|
|
|
176
220
|
})
|
|
177
221
|
```
|
|
178
222
|
|
|
223
|
+
```bash cURL
|
|
224
|
+
# The seller's own packaging
|
|
225
|
+
curl 'https://marketplace.example.com/api/v3/seller/package_types?owner=mine' \
|
|
226
|
+
-H 'Authorization: Bearer $SELLER_JWT' \
|
|
227
|
+
-H 'X-Spree-Seller-Id: sel_xxx'
|
|
228
|
+
|
|
229
|
+
# Theirs plus the marketplace's shared rows
|
|
230
|
+
curl 'https://marketplace.example.com/api/v3/seller/package_types' \
|
|
231
|
+
-H 'Authorization: Bearer $SELLER_JWT' \
|
|
232
|
+
-H 'X-Spree-Seller-Id: sel_xxx'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
|
|
179
236
|
A seller's settings page asks for `owner: 'mine'`. A list mixing both owners
|
|
180
237
|
reads as "packaging is configured" while the seller has recorded nothing of
|
|
181
238
|
their own, which makes the shipping-box requirement look broken rather than
|
|
@@ -62,6 +62,7 @@ Note the difference between `value` and `amount`. `value` is the rule — "10 pe
|
|
|
62
62
|
|
|
63
63
|
Whatever produced them, the rows read the same way:
|
|
64
64
|
|
|
65
|
+
|
|
65
66
|
```typescript Store SDK
|
|
66
67
|
const cart = await client.carts.get(cartId)
|
|
67
68
|
|
|
@@ -75,6 +76,12 @@ cart.discounts.forEach((discount) => {
|
|
|
75
76
|
})
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
```bash cURL
|
|
80
|
+
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
81
|
+
-H 'X-Spree-API-Key: pk_xxx'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
|
|
78
85
|
Applying a coupon code, and what decides whether it's accepted, belongs to [Promotions](promotions.md).
|
|
79
86
|
|
|
80
87
|
## Manual discounts
|
|
@@ -57,7 +57,7 @@ The distinction matters when choosing what to listen for. `order.updated` fires
|
|
|
57
57
|
|
|
58
58
|
An event carries the record it's about, serialized:
|
|
59
59
|
|
|
60
|
-
```json
|
|
60
|
+
```json Event payload
|
|
61
61
|
{
|
|
62
62
|
"id": "or_86Rf07xd4z",
|
|
63
63
|
"number": "R123456789",
|
|
@@ -78,13 +78,19 @@ Point a webhook at your endpoint and subscribe it to the events you care about.
|
|
|
78
78
|
|
|
79
79
|
A subscriber is a small class that names the events it wants and does something when one arrives.
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
|
|
82
|
+
```bash Spree CLI (Docker)
|
|
83
|
+
spree generate subscriber OrderPlaced order.placed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash Without Spree CLI
|
|
82
87
|
bin/rails g spree:subscriber OrderPlaced order.placed
|
|
83
88
|
```
|
|
84
89
|
|
|
90
|
+
|
|
85
91
|
The generator writes the class, a test, and — the step that's easy to forget — registers it.
|
|
86
92
|
|
|
87
|
-
```ruby app/subscribers/order_placed_subscriber.rb
|
|
93
|
+
```ruby server/app/subscribers/order_placed_subscriber.rb
|
|
88
94
|
class OrderPlacedSubscriber < Spree::Subscriber
|
|
89
95
|
subscribes_to 'order.placed'
|
|
90
96
|
|
|
@@ -99,14 +105,14 @@ end
|
|
|
99
105
|
|
|
100
106
|
A subscriber can listen to several events, or to a whole family:
|
|
101
107
|
|
|
102
|
-
```ruby
|
|
108
|
+
```ruby server/app/subscribers/order_placed_subscriber.rb
|
|
103
109
|
subscribes_to 'order.placed', 'order.canceled'
|
|
104
110
|
subscribes_to 'order.*'
|
|
105
111
|
```
|
|
106
112
|
|
|
107
113
|
Subscribers must be registered — they aren't discovered automatically, because a subscriber that starts running because of where its file sits is hard to reason about:
|
|
108
114
|
|
|
109
|
-
```ruby config/initializers/spree.rb
|
|
115
|
+
```ruby server/config/initializers/spree.rb
|
|
110
116
|
Rails.application.config.after_initialize do
|
|
111
117
|
Spree.subscribers << OrderPlacedSubscriber
|
|
112
118
|
end
|
|
@@ -118,7 +124,7 @@ By default a subscriber runs as a background job, so a slow API call in your cod
|
|
|
118
124
|
|
|
119
125
|
That's almost always what you want. If you genuinely need to run inside the same transaction, you can ask to:
|
|
120
126
|
|
|
121
|
-
```ruby
|
|
127
|
+
```ruby server/app/subscribers/order_placed_subscriber.rb
|
|
122
128
|
subscribes_to 'order.placed', async: false
|
|
123
129
|
```
|
|
124
130
|
|
|
@@ -128,7 +134,7 @@ subscribes_to 'order.placed', async: false
|
|
|
128
134
|
|
|
129
135
|
Anything in your own code can announce something, and subscribers and webhooks treat it like any built-in event:
|
|
130
136
|
|
|
131
|
-
```ruby
|
|
137
|
+
```ruby server/app/services/fraud_check.rb
|
|
132
138
|
order.publish_event('order.flagged_for_review')
|
|
133
139
|
```
|
|
134
140
|
|
|
@@ -79,6 +79,7 @@ curl -X POST 'https://api.mystore.com/api/v3/admin/orders/or_xxx/fees' \
|
|
|
79
79
|
|
|
80
80
|
Fees are visible to the storefront on the cart and the order, so a customer sees what they're being charged before they pay:
|
|
81
81
|
|
|
82
|
+
|
|
82
83
|
```typescript Store SDK
|
|
83
84
|
const cart = await client.carts.get(cartId)
|
|
84
85
|
|
|
@@ -90,6 +91,12 @@ cart.fees.forEach((fee) => {
|
|
|
90
91
|
cart.display_fee_total // "$7.50"
|
|
91
92
|
```
|
|
92
93
|
|
|
94
|
+
```bash cURL
|
|
95
|
+
curl 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
96
|
+
-H 'X-Spree-API-Key: pk_xxx'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
|
|
93
100
|
## Fees and tax
|
|
94
101
|
|
|
95
102
|
Fees are **taxable by default** — a handling charge in a VAT country is itself subject to VAT, and Spree writes tax lines against the fee accordingly.
|
|
@@ -122,6 +129,7 @@ For any of this to work, the goods have to be described in the language customs
|
|
|
122
129
|
| `country_of_origin` | Where it was made, as an ISO country code — not where it ships from | `PT` |
|
|
123
130
|
| `customs_description` | A plain description for the declaration, when the product name isn't clear enough | `Cotton t-shirt` |
|
|
124
131
|
|
|
132
|
+
|
|
125
133
|
```typescript Admin SDK
|
|
126
134
|
await adminClient.products.variants.update('prod_xxx', 'var_xxx', {
|
|
127
135
|
hs_code: '6109.10',
|
|
@@ -130,6 +138,18 @@ await adminClient.products.variants.update('prod_xxx', 'var_xxx', {
|
|
|
130
138
|
})
|
|
131
139
|
```
|
|
132
140
|
|
|
141
|
+
```bash cURL
|
|
142
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx/variants/var_xxx' \
|
|
143
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
144
|
+
-H 'Content-Type: application/json' \
|
|
145
|
+
-d '{
|
|
146
|
+
"hs_code": "6109.10",
|
|
147
|
+
"country_of_origin": "PT",
|
|
148
|
+
"customs_description": "Cotton t-shirt"
|
|
149
|
+
}'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
|
|
133
153
|
> **WARNING:** `country_of_origin` is where the item was **manufactured**, not the warehouse it leaves from. Duty rates and trade agreements turn on origin, so getting this wrong produces the wrong charge — and can hold a parcel at the border.
|
|
134
154
|
|
|
135
155
|
These fields are editable per variant and in the bulk product editor. They're also what a carrier integration puts on the customs declaration for an international label — required for cross-border parcels, ignored for domestic ones.
|
|
@@ -322,7 +322,7 @@ integration's metadata, not in the parcel's lifecycle vocabulary. What core
|
|
|
322
322
|
needs to know is only *whether the parcel may go out yet*, and that is exactly
|
|
323
323
|
what the fulfill workflow's validate hook expresses:
|
|
324
324
|
|
|
325
|
-
```ruby
|
|
325
|
+
```ruby server/config/initializers/spree.rb
|
|
326
326
|
Spree.hooks.register('fulfillments.fulfill.validate') do |flow|
|
|
327
327
|
work_order = MyApp::WorkOrder.find_by(fulfillment: flow.fulfillment)
|
|
328
328
|
flow.reject!('still in production') unless work_order&.completed?
|
|
@@ -337,7 +337,7 @@ orders render `fulfilled` as "ready for pickup".
|
|
|
337
337
|
by it, reports group by it, or webhooks fire on entering it, make it a
|
|
338
338
|
first-class status:
|
|
339
339
|
|
|
340
|
-
```ruby config/initializers/spree.rb
|
|
340
|
+
```ruby server/config/initializers/spree.rb
|
|
341
341
|
Spree::Fulfillment.add_status('in_production', after: 'unfulfilled')
|
|
342
342
|
```
|
|
343
343
|
|
|
@@ -346,7 +346,7 @@ a valid value. Moving into it is deliberately not declarative — write a small
|
|
|
346
346
|
workflow, which is also where the printer submission or the workshop handoff
|
|
347
347
|
belongs:
|
|
348
348
|
|
|
349
|
-
```ruby
|
|
349
|
+
```ruby server/app/workflows/my_app/fulfillments/start_production.rb
|
|
350
350
|
class MyApp::Fulfillments::StartProduction < Spree::Workflow
|
|
351
351
|
def perform(fulfillment:)
|
|
352
352
|
super
|
|
@@ -40,7 +40,7 @@ An export is created, runs in the background, and produces a file to download.
|
|
|
40
40
|
|
|
41
41
|
```typescript Admin SDK
|
|
42
42
|
const exportJob = await adminClient.exports.create({
|
|
43
|
-
type: '
|
|
43
|
+
type: 'products',
|
|
44
44
|
})
|
|
45
45
|
|
|
46
46
|
// Poll until it's done
|
|
@@ -50,7 +50,7 @@ status.download_url // available once completed
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
```bash CLI
|
|
53
|
-
spree api post /exports -d '{"type": "
|
|
53
|
+
spree api post /exports -d '{"type": "products"}'
|
|
54
54
|
spree api get /exports
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -59,13 +59,22 @@ spree api get /exports
|
|
|
59
59
|
|
|
60
60
|
An export can carry the same filters as the listing it came from, so "export what I'm looking at" does what a merchant expects — this quarter's orders, this brand's products, not the entire table.
|
|
61
61
|
|
|
62
|
+
|
|
62
63
|
```typescript Admin SDK
|
|
63
64
|
await adminClient.exports.create({
|
|
64
|
-
type: '
|
|
65
|
+
type: 'orders',
|
|
65
66
|
filters: { completed_at_gteq: '2026-01-01' },
|
|
66
67
|
})
|
|
67
68
|
```
|
|
68
69
|
|
|
70
|
+
```bash cURL
|
|
71
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/exports' \
|
|
72
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
73
|
+
-H 'Content-Type: application/json' \
|
|
74
|
+
-d '{ "type": "orders", "filters": { "completed_at_gteq": "2026-01-01" } }'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
|
|
69
78
|
In the dashboard this is the Export button on any list — whatever filters are applied come along.
|
|
70
79
|
|
|
71
80
|
## Importing
|
|
@@ -76,7 +85,7 @@ Importing has an extra step, because a file from somewhere else won't have Spree
|
|
|
76
85
|
|
|
77
86
|
```typescript Admin SDK
|
|
78
87
|
const importJob = await adminClient.imports.create({
|
|
79
|
-
type: '
|
|
88
|
+
type: 'products',
|
|
80
89
|
file: uploadedFileId,
|
|
81
90
|
})
|
|
82
91
|
```
|
|
@@ -117,9 +126,10 @@ Some rows will fail. A price with a currency symbol in it, a required field left
|
|
|
117
126
|
|
|
118
127
|
**A failed row doesn't stop the import.** The good rows are imported; the bad ones are set aside with the reason.
|
|
119
128
|
|
|
129
|
+
|
|
120
130
|
```typescript Admin SDK
|
|
121
131
|
const { data: failures } = await adminClient.imports.rows.list(importJob.id, {
|
|
122
|
-
|
|
132
|
+
status_eq: 'failed',
|
|
123
133
|
})
|
|
124
134
|
|
|
125
135
|
failures.forEach((row) => {
|
|
@@ -131,6 +141,12 @@ failures.forEach((row) => {
|
|
|
131
141
|
await adminClient.imports.retryFailedRows(importJob.id)
|
|
132
142
|
```
|
|
133
143
|
|
|
144
|
+
```bash cURL
|
|
145
|
+
curl 'https://api.mystore.com/api/v3/admin/imports/imp_xxx/rows?q[status_eq]=failed' \
|
|
146
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
|
|
134
150
|
Retrying re-runs only the failures, so a file where three rows out of ten thousand were wrong doesn't need re-importing whole.
|
|
135
151
|
|
|
136
152
|
> **WARNING:** A value that isn't a number is rejected, never guessed at. A price written as `"12,50"` fails rather than being read as `1250` — which would be a hundredfold overcharge. Fix the file rather than hoping the import is lenient.
|
|
@@ -51,7 +51,17 @@ A stock level carries four counts, and a fifth is derived from them. Keeping the
|
|
|
51
51
|
| `incoming_count` | How much is on its way here — on a purchase order placed with a supplier, or a transfer already in transit |
|
|
52
52
|
| **`available_count`** | `count_on_hand − allocated_count` — what's actually sellable, before checkout holds |
|
|
53
53
|
|
|
54
|
-
Reserved and incoming are kept as counters rather than worked out on every read, because the Inventory page in the dashboard reads them for every row, many times a day, and only a handful of things change them: entering or leaving checkout moves reserved; marking a purchase order ordered or a transfer in transit adds to incoming, and receiving, closing, cancelling or reopening the document takes the remainder back off. Nothing else writes them. If a figure ever looks wrong,
|
|
54
|
+
Reserved and incoming are kept as counters rather than worked out on every read, because the Inventory page in the dashboard reads them for every row, many times a day, and only a handful of things change them: entering or leaving checkout moves reserved; marking a purchase order ordered or a transfer in transit adds to incoming, and receiving, closing, cancelling or reopening the document takes the remainder back off. Nothing else writes them. If a figure ever looks wrong, one task recomputes both from their sources and reports every row it corrected — the same task the 6.0 upgrade runs to fill them in for existing stock.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
```bash Spree CLI (Docker)
|
|
58
|
+
spree rake spree:stock:recount_levels
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash Without Spree CLI
|
|
62
|
+
bundle exec rake spree:stock:recount_levels
|
|
63
|
+
```
|
|
64
|
+
|
|
55
65
|
|
|
56
66
|
A transfer counts as incoming from the moment it is marked in transit, not when it is drafted: until the van leaves, nothing is moving. Units a delivery rejects at the dock leave incoming too — they are recorded on the receipt, but they are not coming.
|
|
57
67
|
|
|
@@ -74,6 +84,7 @@ A stock location is somewhere stock physically sits: a warehouse, a shop, a thir
|
|
|
74
84
|
| `pickup_enabled` | Whether customers can collect here |
|
|
75
85
|
| `returns_enabled` | Whether customers can bring returns here |
|
|
76
86
|
|
|
87
|
+
|
|
77
88
|
```typescript Admin SDK
|
|
78
89
|
const { data: locations } = await adminClient.stockLocations.list()
|
|
79
90
|
|
|
@@ -85,6 +96,12 @@ await adminClient.stockLocations.create({
|
|
|
85
96
|
})
|
|
86
97
|
```
|
|
87
98
|
|
|
99
|
+
```bash cURL
|
|
100
|
+
curl 'https://api.mystore.com/api/v3/admin/stock_locations' \
|
|
101
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
|
|
88
105
|
## Reading and adjusting stock
|
|
89
106
|
|
|
90
107
|
A stock level is the intersection of a variant and a location. They're created for you — for every variant when a location propagates all variants, or as variants are added.
|
|
@@ -131,6 +148,7 @@ Every change to stock is recorded as a movement, so "why is this number what it
|
|
|
131
148
|
| `released` | An allocation is given back, because an order was cancelled |
|
|
132
149
|
| `adjusted` | A manual correction, such as after a stock count |
|
|
133
150
|
|
|
151
|
+
|
|
134
152
|
```typescript Admin SDK
|
|
135
153
|
// Everything that happened to one SKU, across every location
|
|
136
154
|
const { data: movements } = await adminClient.stockMovements.list({
|
|
@@ -138,6 +156,12 @@ const { data: movements } = await adminClient.stockMovements.list({
|
|
|
138
156
|
})
|
|
139
157
|
```
|
|
140
158
|
|
|
159
|
+
```bash cURL
|
|
160
|
+
curl 'https://api.mystore.com/api/v3/admin/stock_movements?q[stock_level_variant_id_eq]=variant_xxx' \
|
|
161
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
|
|
141
165
|
Each movement points at what caused it — the order, the fulfillment, the transfer, the purchase order, and for stock that arrived, the delivery it came in on — so an audit trail reads as a sequence of business events rather than a list of numbers that changed.
|
|
142
166
|
|
|
143
167
|
A movement that arrived through a purchase order also carries what the units cost, so "what did this stock cost us" is answerable from the ledger rather than reconstructed from the orders that bought it.
|
|
@@ -146,6 +170,7 @@ A movement that arrived through a purchase order also carries what the units cos
|
|
|
146
170
|
|
|
147
171
|
For a storefront, all of the above collapses into one question, and the answer is already computed:
|
|
148
172
|
|
|
173
|
+
|
|
149
174
|
```typescript Store SDK
|
|
150
175
|
const product = await client.products.get('spree-tote')
|
|
151
176
|
|
|
@@ -155,6 +180,12 @@ product.variants.forEach((variant) => {
|
|
|
155
180
|
})
|
|
156
181
|
```
|
|
157
182
|
|
|
183
|
+
```bash cURL
|
|
184
|
+
curl 'https://api.mystore.com/api/v3/store/products/spree-tote' \
|
|
185
|
+
-H 'X-Spree-API-Key: pk_xxx'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
|
|
158
189
|
`in_stock` already accounts for what's allocated and what other shoppers are holding in checkout. There is nothing to compute client-side, and no separate inventory request to make.
|
|
159
190
|
|
|
160
191
|
## Reservations during checkout
|
|
@@ -188,6 +219,7 @@ A stock transfer moves inventory from one location to another, and reflects that
|
|
|
188
219
|
|
|
189
220
|
That is four steps, not one. A transfer is planned as a draft, packed, sent, and finally counted in:
|
|
190
221
|
|
|
222
|
+
|
|
191
223
|
```typescript Admin SDK
|
|
192
224
|
const transfer = await adminClient.stockTransfers.create({
|
|
193
225
|
source_location_id: 'sloc_xxx',
|
|
@@ -204,6 +236,14 @@ await adminClient.stockTransfers.stockReceipts.create(transfer.id, {
|
|
|
204
236
|
})
|
|
205
237
|
```
|
|
206
238
|
|
|
239
|
+
```bash cURL
|
|
240
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/stock_transfers' \
|
|
241
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
242
|
+
-H 'Content-Type: application/json' \
|
|
243
|
+
-d '{ "source_location_id": "sl_a", "destination_location_id": "sl_b" }'
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
|
|
207
247
|
| Status | What it means |
|
|
208
248
|
|---|---|
|
|
209
249
|
| `draft` | A plan. Lines can still change; nothing has moved |
|
|
@@ -220,6 +260,7 @@ Two things follow from the middle step. Availability at the destination does not
|
|
|
220
260
|
|
|
221
261
|
Stock that comes from a supplier is a purchase order, not a transfer — it has a supplier, a cost per unit and a date it was promised for, none of which apply to moving goods you already own.
|
|
222
262
|
|
|
263
|
+
|
|
223
264
|
```typescript Admin SDK
|
|
224
265
|
const order = await adminClient.purchaseOrders.create({
|
|
225
266
|
supplier_id: 'sup_xxx',
|
|
@@ -238,6 +279,14 @@ await adminClient.purchaseOrders.stockReceipts.create(order.id, {
|
|
|
238
279
|
})
|
|
239
280
|
```
|
|
240
281
|
|
|
282
|
+
```bash cURL
|
|
283
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/purchase_orders' \
|
|
284
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
285
|
+
-H 'Content-Type: application/json' \
|
|
286
|
+
-d '{ "supplier_id": "sup_xxx", "stock_location_id": "sl_xxx" }'
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
|
|
241
290
|
| Status | What it means |
|
|
242
291
|
|---|---|
|
|
243
292
|
| `draft` | Being put together. Lines and costs can still change |
|
|
@@ -255,31 +304,54 @@ Purchase orders export to CSV, one row per line, which is the file a merchant se
|
|
|
255
304
|
|
|
256
305
|
Suppliers themselves are an address book, kept per store:
|
|
257
306
|
|
|
307
|
+
|
|
258
308
|
```typescript Admin SDK
|
|
259
309
|
const { data: suppliers } = await adminClient.suppliers.list()
|
|
260
310
|
```
|
|
261
311
|
|
|
312
|
+
```bash cURL
|
|
313
|
+
curl 'https://api.mystore.com/api/v3/admin/suppliers' \
|
|
314
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
|
|
262
318
|
## Counting a delivery in
|
|
263
319
|
|
|
264
320
|
Transfers and purchase orders are received the same way: each delivery is a **stock receipt**, numbered and dated, carrying the packing-slip reference and — per line — what was accepted and what was refused, with a reason.
|
|
265
321
|
|
|
322
|
+
|
|
266
323
|
```typescript Admin SDK
|
|
267
324
|
const { data: deliveries } = await adminClient.purchaseOrders.stockReceipts.list(order.id, {
|
|
268
325
|
expand: ['items'],
|
|
269
326
|
})
|
|
270
327
|
```
|
|
271
328
|
|
|
329
|
+
```bash cURL
|
|
330
|
+
curl 'https://api.mystore.com/api/v3/admin/purchase_orders/po_xxx/stock_receipts' \
|
|
331
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
|
|
272
335
|
A receipt records *this delivery's* counts, not running totals: a second box adds to the first rather than restating it. Accepted units land on the destination's shelf, each as a `received` movement that names the receipt. Refused units are recorded and never stocked; the reason is one of `damaged`, `wrong_item`, `expired` or `other`. Omit `items` to count in everything still outstanding, intact.
|
|
273
336
|
|
|
274
337
|
> **NOTE:** **Refused units count differently on the two documents.** A supplier's refused units are still owed — the goods go back, and the order stays `partially_received` until they are replaced. A transfer's refused units have *arrived*, in whatever state: a crushed unit is not still in the van, so it counts toward the trip being over.
|
|
275
338
|
|
|
276
339
|
When the balance is not coming — the supplier is out of stock, the missing units fell off the van — the document is **closed short**: it settles in `received` with `closed_short_at` and the reason recorded, and the outstanding count stays on each line as the record of the gap. Nothing moves.
|
|
277
340
|
|
|
341
|
+
|
|
278
342
|
```typescript Admin SDK
|
|
279
343
|
await adminClient.purchaseOrders.close(order.id, { reason: 'Supplier out of stock' })
|
|
280
344
|
await adminClient.stockTransfers.close(transfer.id, { reason: 'Two fell off the van' })
|
|
281
345
|
```
|
|
282
346
|
|
|
347
|
+
```bash cURL
|
|
348
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/purchase_orders/po_xxx/close' \
|
|
349
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
350
|
+
-H 'Content-Type: application/json' \
|
|
351
|
+
-d '{ "reason": "Supplier out of stock" }'
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
|
|
283
355
|
## The Inventory page
|
|
284
356
|
|
|
285
357
|
The dashboard's Inventory section opens on a list with one row per variant per location: on hand, allocated, reserved, available and incoming — the page uses the column names, with the location as a filter and SKU or product name as the search. Every figure on it is a column read off the stock level.
|
|
@@ -14,8 +14,8 @@ Markets let you segment a single [Store](stores.md) into distinct geographic reg
|
|
|
14
14
|
```mermaid
|
|
15
15
|
erDiagram
|
|
16
16
|
Store ||--o{ Market : "has many"
|
|
17
|
-
Market ||--o{ MarketCountry : "
|
|
18
|
-
|
|
17
|
+
Market ||--o{ MarketCountry : "covers"
|
|
18
|
+
Market ||--o{ Order : "records"
|
|
19
19
|
|
|
20
20
|
Store {
|
|
21
21
|
string name
|
|
@@ -31,9 +31,8 @@ erDiagram
|
|
|
31
31
|
boolean default
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
string
|
|
36
|
-
string name
|
|
34
|
+
MarketCountry {
|
|
35
|
+
string country_code
|
|
37
36
|
}
|
|
38
37
|
```
|
|
39
38
|
|
|
@@ -53,7 +52,7 @@ erDiagram
|
|
|
53
52
|
|
|
54
53
|
When a customer visits your store, their country determines which market applies. The market then sets the currency, locale, and tax behavior for that session.
|
|
55
54
|
|
|
56
|
-
```
|
|
55
|
+
```text
|
|
57
56
|
Customer's Country → Market → Currency + Locale + Tax treatment
|
|
58
57
|
```
|
|
59
58
|
|