@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.
Files changed (46) hide show
  1. package/dist/api-reference/store.yaml +28 -0
  2. package/dist/developer/agentic/agent-skills.md +1 -1
  3. package/dist/developer/cli/quickstart.md +2 -2
  4. package/dist/developer/core-concepts/addresses.md +13 -0
  5. package/dist/developer/core-concepts/calculators.md +6 -1
  6. package/dist/developer/core-concepts/carts.md +19 -1
  7. package/dist/developer/core-concepts/catalogs.md +51 -2
  8. package/dist/developer/core-concepts/channels.md +20 -1
  9. package/dist/developer/core-concepts/commissions.md +70 -1
  10. package/dist/developer/core-concepts/companies.md +74 -0
  11. package/dist/developer/core-concepts/customers.md +17 -5
  12. package/dist/developer/core-concepts/data-privacy.md +20 -7
  13. package/dist/developer/core-concepts/delivery-setup.md +57 -0
  14. package/dist/developer/core-concepts/discounts.md +7 -0
  15. package/dist/developer/core-concepts/events.md +13 -7
  16. package/dist/developer/core-concepts/fees.md +20 -0
  17. package/dist/developer/core-concepts/fulfillments.md +3 -3
  18. package/dist/developer/core-concepts/imports-exports.md +21 -5
  19. package/dist/developer/core-concepts/inventory.md +73 -1
  20. package/dist/developer/core-concepts/markets.md +5 -6
  21. package/dist/developer/core-concepts/media.md +66 -1
  22. package/dist/developer/core-concepts/metafields.md +1 -1
  23. package/dist/developer/core-concepts/order-totals.md +18 -1
  24. package/dist/developer/core-concepts/orders.md +2 -1
  25. package/dist/developer/core-concepts/payments.md +100 -20
  26. package/dist/developer/core-concepts/pricing.md +13 -8
  27. package/dist/developer/core-concepts/products.md +52 -1
  28. package/dist/developer/core-concepts/reporting.md +5 -5
  29. package/dist/developer/core-concepts/returns-exchanges-claims.md +41 -2
  30. package/dist/developer/core-concepts/search-filtering.md +1 -2
  31. package/dist/developer/core-concepts/sellers.md +109 -2
  32. package/dist/developer/core-concepts/slugs.md +1 -1
  33. package/dist/developer/core-concepts/staff-roles.md +23 -3
  34. package/dist/developer/core-concepts/store-credits-gift-cards.md +60 -23
  35. package/dist/developer/core-concepts/stores.md +21 -0
  36. package/dist/developer/core-concepts/taxes.md +43 -0
  37. package/dist/developer/core-concepts/translations.md +9 -0
  38. package/dist/developer/core-concepts/webhooks.md +45 -3
  39. package/dist/developer/create-spree-app/quickstart.md +10 -10
  40. package/dist/developer/dashboard/deployment.md +4 -4
  41. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +1 -1
  42. package/dist/developer/deployment/docker.md +2 -2
  43. package/dist/developer/how-to/custom-promotion.md +33 -13
  44. package/dist/developer/multi-tenant/quickstart.md +6 -6
  45. package/dist/developer/storefront/nextjs/testing.md +2 -2
  46. 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
- ```bash
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
- ```bash
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: 'Spree::Exports::Products',
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": "Spree::Exports::Products"}'
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: 'Spree::Exports::Orders',
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: 'Spree::Imports::Products',
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
- filter: { status_eq: 'failed' },
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, `rake spree:stock:recount_levels` 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.
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 : "has many"
18
- MarketCountry }o--|| Country : "belongs to"
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
- Country {
35
- string iso
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