@spree/docs 0.1.229 → 0.1.231
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 +78 -78
- 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/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
|
@@ -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
|
|
|
@@ -22,6 +22,7 @@ flowchart LR
|
|
|
22
22
|
|
|
23
23
|
Every file in a store is in the library, whether or not it's currently placed on anything. That means a file can be uploaded ahead of time, browsed, searched, and reused.
|
|
24
24
|
|
|
25
|
+
|
|
25
26
|
```typescript Admin SDK
|
|
26
27
|
// Browse everything in the store
|
|
27
28
|
const { data: media } = await adminClient.media.list()
|
|
@@ -30,6 +31,17 @@ const { data: media } = await adminClient.media.list()
|
|
|
30
31
|
const { data: usage } = await adminClient.media.usage('med_xxx')
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
```bash cURL
|
|
35
|
+
# Browse everything in the store
|
|
36
|
+
curl 'https://api.mystore.com/api/v3/admin/media' \
|
|
37
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
38
|
+
|
|
39
|
+
# Where is this file being used?
|
|
40
|
+
curl 'https://api.mystore.com/api/v3/admin/media/med_xxx/usage' \
|
|
41
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
|
|
33
45
|
| Field | What it tells you |
|
|
34
46
|
|---|---|
|
|
35
47
|
| `attached` | Whether the file is placed on anything yet |
|
|
@@ -43,6 +55,7 @@ const { data: usage } = await adminClient.media.usage('med_xxx')
|
|
|
43
55
|
|
|
44
56
|
Placing a library file on a product creates a **new media row that shares the same underlying file**:
|
|
45
57
|
|
|
58
|
+
|
|
46
59
|
```typescript Admin SDK
|
|
47
60
|
await adminClient.products.media.create('prod_xxx', {
|
|
48
61
|
source_media_id: 'med_xxx',
|
|
@@ -51,6 +64,14 @@ await adminClient.products.media.create('prod_xxx', {
|
|
|
51
64
|
})
|
|
52
65
|
```
|
|
53
66
|
|
|
67
|
+
```bash cURL
|
|
68
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_xxx/media' \
|
|
69
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
70
|
+
-H 'Content-Type: application/json' \
|
|
71
|
+
-d '{ "source_media_id": "med_xxx", "alt": "Worn over a navy jumper", "position": 2 }'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
|
|
54
75
|
The copy gets its own alt text, its own position, and its own variant links — because the same photograph might be the third image on one product and the hero on another, with different alt text describing what matters in each context. What it doesn't get is a second copy of the bytes.
|
|
55
76
|
|
|
56
77
|
> **NOTE:** This is why there's no single "shared asset" record to manage. Each placement is independently editable, while storage is shared. Detaching a photo from one product has no effect on any other.
|
|
@@ -59,6 +80,7 @@ The copy gets its own alt text, its own position, and its own variant links —
|
|
|
59
80
|
|
|
60
81
|
A file in use can't be deleted by accident:
|
|
61
82
|
|
|
83
|
+
|
|
62
84
|
```typescript Admin SDK
|
|
63
85
|
// Returns 422 with the list of places it's used
|
|
64
86
|
await adminClient.media.delete('med_xxx')
|
|
@@ -67,6 +89,17 @@ await adminClient.media.delete('med_xxx')
|
|
|
67
89
|
await adminClient.media.delete('med_xxx', { detach: true })
|
|
68
90
|
```
|
|
69
91
|
|
|
92
|
+
```bash cURL
|
|
93
|
+
# Returns 422 with the list of places it is used
|
|
94
|
+
curl -X DELETE 'https://api.mystore.com/api/v3/admin/media/med_xxx' \
|
|
95
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
96
|
+
|
|
97
|
+
# Remove it from everywhere, then delete
|
|
98
|
+
curl -X DELETE 'https://api.mystore.com/api/v3/admin/media/med_xxx?detach=true' \
|
|
99
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
|
|
70
103
|
The dashboard shows you the usage list and asks before doing the second one. Check `usage` before offering a delete in your own tooling.
|
|
71
104
|
|
|
72
105
|
## What can carry media
|
|
@@ -227,6 +260,7 @@ curl -X POST 'https://api.mystore.com/api/v3/admin/products/prod_86Rf07xd4z/medi
|
|
|
227
260
|
|
|
228
261
|
A hosted video is uploaded the same way an image is, with `media_type` telling Spree what it is. Add `poster_signed_id` to give it a still frame:
|
|
229
262
|
|
|
263
|
+
|
|
230
264
|
```typescript Admin SDK
|
|
231
265
|
const video = await client.products.media.create('prod_86Rf07xd4z', {
|
|
232
266
|
media_type: 'video',
|
|
@@ -236,14 +270,36 @@ const video = await client.products.media.create('prod_86Rf07xd4z', {
|
|
|
236
270
|
})
|
|
237
271
|
```
|
|
238
272
|
|
|
273
|
+
```bash cURL
|
|
274
|
+
curl -X POST 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media' \
|
|
275
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
276
|
+
-H 'Content-Type: application/json' \
|
|
277
|
+
-d '{
|
|
278
|
+
"media_type": "video",
|
|
279
|
+
"signed_id": "SIGNED_VIDEO_BLOB_ID",
|
|
280
|
+
"poster_signed_id": "SIGNED_POSTER_BLOB_ID",
|
|
281
|
+
"alt": "Product in use"
|
|
282
|
+
}'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
|
|
239
286
|
A poster can also be added or replaced later, on its own:
|
|
240
287
|
|
|
288
|
+
|
|
241
289
|
```typescript Admin SDK
|
|
242
290
|
await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
|
|
243
291
|
poster_signed_id: signedPosterBlobId,
|
|
244
292
|
})
|
|
245
293
|
```
|
|
246
294
|
|
|
295
|
+
```bash cURL
|
|
296
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
|
|
297
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
298
|
+
-H 'Content-Type: application/json' \
|
|
299
|
+
-d '{ "poster_signed_id": "SIGNED_POSTER_BLOB_ID" }'
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
|
|
247
303
|
> **INFO:** Spree serves an uploaded video as you uploaded it — it does not transcode. Keep files web-friendly (H.264 MP4 or WebM) so they play everywhere, and prefer an external video for long footage so the provider handles streaming.
|
|
248
304
|
|
|
249
305
|
#### Posters
|
|
@@ -262,6 +318,7 @@ Spree does not extract a frame from an uploaded video, so give hosted video and
|
|
|
262
318
|
|
|
263
319
|
`focal_point_x` and `focal_point_y` mark the part of an image that must stay in frame when a storefront crops it to a different shape. Both are fractions between 0 and 1, measured from the top left, so `{ x: 0.5, y: 0.5 }` is dead centre — which is also what a storefront should assume when they are null.
|
|
264
320
|
|
|
321
|
+
|
|
265
322
|
```typescript Admin SDK
|
|
266
323
|
await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
|
|
267
324
|
focal_point_x: 0.25,
|
|
@@ -269,6 +326,14 @@ await client.products.media.update('prod_86Rf07xd4z', 'media_k5nR8xLq', {
|
|
|
269
326
|
})
|
|
270
327
|
```
|
|
271
328
|
|
|
329
|
+
```bash cURL
|
|
330
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/products/prod_86Rf07xd4z/media/media_k5nR8xLq' \
|
|
331
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
332
|
+
-H 'Content-Type: application/json' \
|
|
333
|
+
-d '{ "focal_point_x": 0.25, "focal_point_y": 0.4 }'
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
|
|
272
337
|
Spree stores the focal point and serves it; the cropping itself is the storefront's decision, since only it knows the shape it needs.
|
|
273
338
|
|
|
274
339
|
### Named Variant Sizes
|
|
@@ -349,7 +414,7 @@ curl 'https://api.mystore.com/api/v3/store/products/spree-tote?expand=media,vari
|
|
|
349
414
|
|
|
350
415
|
**Response (media object):**
|
|
351
416
|
|
|
352
|
-
```json
|
|
417
|
+
```json Response
|
|
353
418
|
{
|
|
354
419
|
"id": "media_k5nR8xLq",
|
|
355
420
|
"media_type": "image",
|
|
@@ -170,7 +170,7 @@ A custom field can take part in product listings — but only if you ask, becaus
|
|
|
170
170
|
|
|
171
171
|
Setting either flag also makes the field **filterable**. Filter and sort with the field's `cf_` key, on both APIs:
|
|
172
172
|
|
|
173
|
-
```
|
|
173
|
+
```http
|
|
174
174
|
GET /api/v3/store/products?q[cf_properties_material_i_cont]=wool
|
|
175
175
|
GET /api/v3/store/products?sort=-cf_properties_weight
|
|
176
176
|
GET /api/v3/admin/products?q[cf_properties_weight_gteq]=3.5
|
|
@@ -22,6 +22,7 @@ flowchart TB
|
|
|
22
22
|
|
|
23
23
|
## The totals
|
|
24
24
|
|
|
25
|
+
|
|
25
26
|
```typescript Store SDK
|
|
26
27
|
const order = await client.orders.get('or_xxx')
|
|
27
28
|
|
|
@@ -33,6 +34,13 @@ order.display_tax_total // "$22.60" all tax
|
|
|
33
34
|
order.display_total // "$138.10" what the customer pays
|
|
34
35
|
```
|
|
35
36
|
|
|
37
|
+
```bash cURL
|
|
38
|
+
curl 'https://api.mystore.com/api/v3/store/orders/or_xxx' \
|
|
39
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
40
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
|
36
44
|
| Attribute | What it sums |
|
|
37
45
|
|---|---|
|
|
38
46
|
| `item_total` | Line item prices, before tax and discounts |
|
|
@@ -49,7 +57,7 @@ order.display_total // "$138.10" what the customer pays
|
|
|
49
57
|
|
|
50
58
|
Every total comes twice: `total` is the raw value, `display_total` is formatted for the order's currency.
|
|
51
59
|
|
|
52
|
-
```json
|
|
60
|
+
```json Response
|
|
53
61
|
{
|
|
54
62
|
"total": "138.10",
|
|
55
63
|
"display_total": "$138.10"
|
|
@@ -74,6 +82,7 @@ The two exist because the same order can carry both: VAT already inside the good
|
|
|
74
82
|
|
|
75
83
|
Every change to a cart returns the whole cart, totals included:
|
|
76
84
|
|
|
85
|
+
|
|
77
86
|
```typescript Store SDK
|
|
78
87
|
const cart = await client.carts.items.create(cartId, {
|
|
79
88
|
variant_id: 'var_xxx',
|
|
@@ -83,6 +92,14 @@ const cart = await client.carts.items.create(cartId, {
|
|
|
83
92
|
cart.display_total // already correct
|
|
84
93
|
```
|
|
85
94
|
|
|
95
|
+
```bash cURL
|
|
96
|
+
curl -X POST 'https://api.mystore.com/api/v3/store/carts/cart_xxx/items' \
|
|
97
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
98
|
+
-H 'Content-Type: application/json' \
|
|
99
|
+
-d '{ "variant_id": "variant_xxx", "quantity": 2 }'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
|
|
86
103
|
There is never a second "recalculate" request to make, and never a moment where the summary on screen disagrees with what the server thinks. Adding an item, entering an address, applying a code — each response carries the new numbers.
|
|
87
104
|
|
|
88
105
|
The totals are worked out again at the moment the cart is completed, so a price that changed while the customer sat on the review page can't lead to the wrong charge.
|
|
@@ -114,7 +114,8 @@ Back-office work happens through the Admin API.
|
|
|
114
114
|
```typescript Admin SDK
|
|
115
115
|
// Find orders needing attention
|
|
116
116
|
const orders = await adminClient.orders.list({
|
|
117
|
-
|
|
117
|
+
payment_status_eq: 'paid',
|
|
118
|
+
fulfillment_status_eq: 'unfulfilled',
|
|
118
119
|
})
|
|
119
120
|
|
|
120
121
|
// Cancel an order, recording why from your own list of reasons
|