@spree/docs 0.1.182 → 0.1.184

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 (34) hide show
  1. package/dist/developer/core-concepts/addresses.md +106 -198
  2. package/dist/developer/core-concepts/architecture.md +97 -126
  3. package/dist/developer/core-concepts/calculators.md +75 -252
  4. package/dist/developer/core-concepts/carts.md +1 -1
  5. package/dist/developer/core-concepts/catalogs.md +140 -0
  6. package/dist/developer/core-concepts/channels.md +0 -4
  7. package/dist/developer/core-concepts/commissions.md +253 -0
  8. package/dist/developer/core-concepts/companies.md +240 -0
  9. package/dist/developer/core-concepts/customers.md +0 -3
  10. package/dist/developer/core-concepts/discounts.md +133 -0
  11. package/dist/developer/core-concepts/events.md +83 -576
  12. package/dist/developer/core-concepts/fees.md +144 -0
  13. package/dist/developer/core-concepts/imports-exports.md +105 -679
  14. package/dist/developer/core-concepts/inventory.md +114 -248
  15. package/dist/developer/core-concepts/markets.md +9 -12
  16. package/dist/developer/core-concepts/media.md +127 -16
  17. package/dist/developer/core-concepts/metafields.md +123 -200
  18. package/dist/developer/core-concepts/order-totals.md +110 -0
  19. package/dist/developer/core-concepts/orders.md +1 -1
  20. package/dist/developer/core-concepts/payments.md +11 -14
  21. package/dist/developer/core-concepts/pricing.md +11 -13
  22. package/dist/developer/core-concepts/products.md +191 -62
  23. package/dist/developer/core-concepts/promotions.md +12 -11
  24. package/dist/developer/core-concepts/search-filtering.md +2 -4
  25. package/dist/developer/core-concepts/sellers.md +210 -0
  26. package/dist/developer/core-concepts/staff-roles.md +56 -23
  27. package/dist/developer/core-concepts/store-credits-gift-cards.md +0 -3
  28. package/dist/developer/core-concepts/taxes.md +125 -113
  29. package/dist/developer/core-concepts/translations.md +61 -68
  30. package/dist/developer/core-concepts/webhooks.md +25 -59
  31. package/dist/developer/how-to/custom-promotion.md +3 -3
  32. package/package.json +1 -1
  33. package/dist/developer/core-concepts/companies-and-catalogs.md +0 -81
  34. package/dist/developer/core-concepts/taxes-discounts-fees.md +0 -199
@@ -0,0 +1,110 @@
1
+ ---
2
+ title: Order Totals
3
+ description: How an order total is built up from items, tax, discounts, delivery and fees — and how to render a summary that adds up.
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ An order total is rarely just the sum of the item prices. Tax is added or already inside them, a promo code takes something off, delivery costs something, gift wrapping costs a bit more.
9
+
10
+ Spree records each of those as its own kind of row, and keeps a running total for each kind on the order itself. That means the summary block in your checkout is a handful of fields — you don't add anything up yourself.
11
+
12
+ ```mermaid
13
+ flowchart TB
14
+ Items["Item total"] --> Total["Order total"]
15
+ Discounts["Discounts"] --> Total
16
+ Delivery["Delivery"] --> Total
17
+ Fees["Fees"] --> Total
18
+ Tax["Tax"] --> Total
19
+
20
+ style Total fill:#e8f5e9,stroke:#2e7d32
21
+ ```
22
+
23
+ ## The totals
24
+
25
+ ```typescript Store SDK
26
+ const order = await client.orders.get('or_xxx')
27
+
28
+ order.display_item_total // "$120.00" items, before tax and discounts
29
+ order.display_discount_total // "-$12.00" everything taken off
30
+ order.display_delivery_total // "$5.00" delivery
31
+ order.display_fee_total // "$2.50" surcharges
32
+ order.display_tax_total // "$22.60" all tax
33
+ order.display_total // "$138.10" what the customer pays
34
+ ```
35
+
36
+ | Attribute | What it sums |
37
+ |---|---|
38
+ | `item_total` | Line item prices, before tax and discounts |
39
+ | `discount_total` | Every [discount](discounts.md) |
40
+ | `delivery_total` | Delivery charges |
41
+ | `fee_total` | Every [fee](fees.md) |
42
+ | `tax_total` | All [tax](taxes.md) |
43
+ | `included_tax_total` | Tax already inside the displayed prices |
44
+ | `additional_tax_total` | Tax added on top |
45
+ | `total` | What the customer pays |
46
+ | `amount_due` | Still to pay, after gift cards and store credit |
47
+
48
+ ## Two forms of every amount
49
+
50
+ Every total comes twice: `total` is the raw value, `display_total` is formatted for the order's currency.
51
+
52
+ ```json
53
+ {
54
+ "total": "138.10",
55
+ "display_total": "$138.10"
56
+ }
57
+ ```
58
+
59
+ **Render the `display_` one.** It knows the currency symbol, which side it goes on, and which separator that locale uses — `$138.10`, `138,10 €`, `¥138`. Formatting it yourself means reimplementing that, and getting it wrong for somebody.
60
+
61
+ > **NOTE:** Money is a **string**, never a number. JavaScript can't represent every decimal exactly: `0.1 + 0.2` gives `0.30000000000000004`, which is not something you want inside a price.
62
+ >
63
+ > If you must do arithmetic, use a decimal library or work in whole cents. Most of the time you don't need to — Spree already did it.
64
+
65
+ ## The double-counting trap
66
+
67
+ > **WARNING:** If you build a subtotal yourself, include `additional_tax_total` only — never `included_tax_total`.
68
+ >
69
+ > Included tax is **already inside** the item prices. Adding it again charges the customer's eyes twice, and produces a summary that doesn't match the amount taken from their card. This is the single most common bug in a European storefront.
70
+
71
+ The two exist because the same order can carry both: VAT already inside the goods, and a separately-added tax on something else. `tax_total` covers both, which is why it's the safe field to display on its own line.
72
+
73
+ ## Totals update themselves
74
+
75
+ Every change to a cart returns the whole cart, totals included:
76
+
77
+ ```typescript Store SDK
78
+ const cart = await client.carts.items.create(cartId, {
79
+ variant_id: 'var_xxx',
80
+ quantity: 2,
81
+ })
82
+
83
+ cart.display_total // already correct
84
+ ```
85
+
86
+ 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
+
88
+ 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.
89
+
90
+ ## Once an order is placed
91
+
92
+ The rows stop being regenerated. Editing a placed order re-adds the rows it already has rather than starting over, so today's promotions and rates can't rewrite what a customer agreed to last week.
93
+
94
+ ## Where each row attaches
95
+
96
+ The individual rows are there if you need them — an itemised invoice, a tax report:
97
+
98
+ - **[Tax lines](taxes.md)** → a line item, a fulfillment, or a fee
99
+ - **[Discounts](discounts.md)** → a line item or a fulfillment
100
+ - **[Fees](fees.md)** → a line item, a fulfillment, or the order itself
101
+
102
+ Each row also keeps a copy of where it came from — the rate and label on a tax line, the code and promotion on a discount. If someone deletes that promotion next month, the order still says what the customer was actually given.
103
+
104
+ ## Related
105
+
106
+ - [Taxes](taxes.md) — how tax is worked out
107
+ - [Discounts](discounts.md) — money off
108
+ - [Fees](fees.md) — surcharges and duties
109
+ - [Carts](carts.md) — checkout and completion
110
+ - [Orders](orders.md) — the placed order
@@ -148,7 +148,7 @@ Where admin edits are allowed, totals are re-added from the order's existing row
148
148
 
149
149
  ## Money on an order
150
150
 
151
- Tax, discounts and fees are kept as separate records, so you can ask what tax was charged without picking through a mixed list. See [Taxes, Discounts & Fees](taxes-discounts-fees.md).
151
+ Tax, discounts and fees are kept as separate records, so you can ask what tax was charged without picking through a mixed list. See [Order totals](order-totals.md).
152
152
 
153
153
  ## Events
154
154
 
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Payments
3
+ description: Taking money — payment methods, payment sessions, saved cards, refunds, and how a payment finishes reliably even when a customer closes the tab.
3
4
  ---
4
5
 
5
6
  ## Overview
@@ -98,7 +99,7 @@ erDiagram
98
99
  CreditCard }o--|| Customer : "belongs to"
99
100
  StoreCredit }o--|| Customer : "belongs to"
100
101
  Refund }o--|| Payment : "belongs to"
101
- Refund }o--|| Reimbursement : "belongs to"
102
+ Refund }o--o| Return : "may come from"
102
103
  ```
103
104
 
104
105
  **Key relationships:**
@@ -681,22 +682,18 @@ See [Events](events.md) for more details on subscribing to events.
681
682
 
682
683
  - [Payments (Store SDK)](../sdk/store/payments.md) - SDK how-to for payment sessions, payments, and setup sessions
683
684
  - [Build a Custom Payment Method](../how-to/custom-payment-method.md) - Step-by-step guide to creating your own payment gateway integration
684
- - [Orders](orders.md) - Order management and state machine
685
+ - [Orders](orders.md) - Order lifecycle, payment and fulfillment status
685
686
  - [Checkout Customization](carts.md) - Customizing the checkout flow
686
687
  - [Events](events.md) - Subscribe to payment events
687
688
 
688
- ## Key Services
689
+ ## Two paths to a completed order
689
690
 
690
- | Service | Description |
691
- |---------|-------------|
692
- | `Spree::Carts::Complete` | Completes the order — validates, processes payments (if not already done), advances state machine. Used by both the `POST /carts/:id/complete` endpoint and the webhook handler. |
693
- | `Spree::Payments::HandleWebhook` | Processes a normalized webhook event — creates Payment, marks session completed, calls `Carts::Complete`. |
694
- | `Spree::Payments::HandleWebhookJob` | Background job that wraps `HandleWebhook` — enqueued by the webhook controller for async processing. |
691
+ A payment can finish in two places, and both have to end at the same result.
695
692
 
696
- Both `Carts::Complete` and `HandleWebhook` are registered in `Spree::Dependencies` and can be replaced with custom implementations:
693
+ The customer's browser confirms the payment and your storefront completes the cart. Or the provider's own webhook arrives first — sometimes seconds later, sometimes because the customer closed the tab mid-redirect.
697
694
 
698
- ```ruby
699
- # config/initializers/spree.rb
700
- Spree::Dependencies.carts_complete_service = 'MyApp::CustomCartComplete'
701
- Spree::Dependencies.payments_handle_webhook_service = 'MyApp::CustomWebhookHandler'
702
- ```
695
+ Whichever arrives first completes the order; the other finds the work already done and does nothing. That is what makes a closed tab or a flaky connection recoverable rather than a lost sale with a real charge attached.
696
+
697
+ > **WARNING:** Never treat the browser returning from a redirect as proof of payment. The provider's webhook is the authoritative signal — a customer can close the tab, and a browser response can be forged.
698
+
699
+ Both paths can be replaced if your integration needs different behaviour — see [Dependencies](../customization/dependencies.md).
@@ -3,8 +3,6 @@ title: Pricing
3
3
  description: Prices, Price Lists, Price Rules, and the Pricing Context — Spree's flexible pricing engine for regional, wholesale, volume, and market-based pricing.
4
4
  ---
5
5
 
6
- import { Since } from '/snippets/since.mdx';
7
-
8
6
  ## Overview
9
7
 
10
8
  Spree's pricing system supports both simple single-currency pricing and advanced multi-currency, rule-based pricing through Price Lists. Every [Variant](products.md#variants) can have multiple prices — a base price per currency, plus additional prices from Price Lists that apply conditionally based on rules like geography, customer segment, or quantity.
@@ -94,7 +92,7 @@ spree api post /prices/bulk_upsert -d '{
94
92
  ```
95
93
 
96
94
 
97
- ## Price Lists
95
+ ## Price Lists
98
96
 
99
97
  Price Lists allow you to create different pricing strategies based on various conditions. This enables advanced pricing scenarios like:
100
98
 
@@ -165,27 +163,27 @@ spree api patch /price_lists/pl_xxx/activate
165
163
  ```
166
164
 
167
165
 
168
- ## Price Rules
166
+ ## Price Rules
169
167
 
170
168
  Price Rules define conditions that must be met for a Price List to apply. Spree includes five built-in rule types:
171
169
 
172
170
  | Rule | Description | Use Case |
173
171
  |------|-------------|----------|
174
172
  | **Market Rule** | Matches based on the current [Market](markets.md) | Regional pricing across markets |
175
- | **Zone Rule** | Matches based on the customer's geographic zone | Country or state-level pricing |
173
+ | **Channel Rule** | Matches based on the [Channel](channels.md) the customer is buying through | App-only or in-store pricing |
176
174
  | **User Rule** | Matches specific customer accounts | VIP customers, wholesale accounts |
177
175
  | **Customer Group Rule** | Matches members of customer groups | Loyalty tiers, membership pricing |
178
176
  | **Volume Rule** | Matches based on quantity purchased | Bulk discounts, tiered pricing |
179
177
 
180
- ### Market Rule
178
+ ### Market Rule
181
179
 
182
- The recommended approach for regional pricing when using Markets. Applies the Price List when the customer is in one of the specified markets.
180
+ Regional pricing. Applies the Price List when the customer is in one of the specified markets.
183
181
 
184
182
  **Example:** Price a product at $29.99 in North America and €24.99 in Europe, rather than relying on exchange rate conversion.
185
183
 
186
- ### Zone Rule
184
+ ### Channel Rule
187
185
 
188
- Applies based on the customer's geographic zone. Useful for regional or country-specific pricing when not using Markets.
186
+ Applies when the customer is buying through a particular sales channel — a discount that exists only in the mobile app, or a price that only applies at a retail till.
189
187
 
190
188
  ### User Rule
191
189
 
@@ -207,7 +205,7 @@ Applies based on quantity purchased. Supports `min_quantity` and `max_quantity`
207
205
 
208
206
  > **INFO:** Custom Price Rules can be created for specialized pricing logic. See the [Customization Quickstart](../customization/quickstart.md) for details.
209
207
 
210
- ## Pricing Context
208
+ ## Pricing Context
211
209
 
212
210
  When resolving prices, Spree considers the full context of the request:
213
211
 
@@ -215,8 +213,8 @@ When resolving prices, Spree considers the full context of the request:
215
213
  |---------|--------|-------------|
216
214
  | Currency | Market or request header | The currency to price in |
217
215
  | Market | Customer's country | The [Market](markets.md) for market-based rules |
218
- | Zone | Customer's address | The geographic zone for zone-based rules |
219
- | Customer | JWT authentication | The logged-in customer for user-based rules |
216
+ | Channel | The API credential in use | The [Channel](channels.md) for channel-based rules |
217
+ | Customer | Authentication | The signed-in customer for user-based rules |
220
218
  | Quantity | Cart line item | The quantity for volume-based rules |
221
219
  | Date | Current time | For time-based Price List scheduling |
222
220
 
@@ -236,7 +234,7 @@ Price Lists are managed in the Admin Panel under **Products → Price Lists**, o
236
234
 
237
235
  Each Price List contains prices for specific variants and currencies. Products can be added to a Price List, and individual variant prices set within it.
238
236
 
239
- ## Price History (EU Omnibus Directive)
237
+ ## Price History (EU Omnibus Directive)
240
238
 
241
239
  Spree automatically records price changes for EU Omnibus Directive compliance. When a product goes on sale, EU regulations require displaying the lowest price in the preceding 30 days alongside the discounted price.
242
240
 
@@ -3,28 +3,29 @@ title: Products
3
3
  description: How Spree models products, variants, option types, images, prices, and categories — the building blocks of every catalog and storefront.
4
4
  ---
5
5
 
6
- import { Since } from '/snippets/since.mdx';
7
-
8
6
  ## Overview
9
7
 
10
- A product represents something you sell. Each product has one or more **variants** the actual purchasable items with their own SKU, price, and inventory. For example, a "T-Shirt" product might have variants for each size and color combination.
8
+ A product is the listing a customer browses. A **variant** is the thing they actually buy.
9
+
10
+ That split runs through everything on this page. The product holds what's shared — name, description, images, which categories it's filed under. Each variant holds what differs — its SKU, its price, its stock. A T-shirt is one product; small navy is a variant.
11
11
 
12
- Products are organized into **categories** a flexible hierarchy for grouping products. Categories can be filtered, sorted, and searched via the Store API.
12
+ **Every product has at least one variant**, even when there's nothing to choose. A book with no size or colour still has a single variant carrying its SKU, price and stock; you just never render a picker.
13
13
 
14
- > **INFO:** Product names, descriptions, slugs, and SEO fields are [translatable](translations.md#resource-translations).
14
+ > **INFO:** Product names, descriptions, slugs and SEO fields are [translatable](translations.md#resource-translations).
15
15
 
16
16
  ```mermaid
17
17
  erDiagram
18
18
  Product ||--o{ Variant : "has many"
19
- Product }o--o{ OptionType : "has many"
20
- Product ||--o{ Classification : "has many"
21
- Variant ||--o{ Price : "has many"
22
- Variant ||--o{ StockItem : "has many"
23
- Variant ||--o{ Image : "has many"
24
- Variant }o--o{ OptionValue : "has many"
19
+ Product }o--o{ OptionType : "varies by"
20
+ Product }o--o{ Category : "filed under"
21
+ Product }o--o{ Collection : "grouped into"
22
+ Product ||--o{ Media : "images and video"
23
+ Product }o--|| DeliveryProfile : "ships by"
24
+ Variant ||--o{ Price : "one per currency"
25
+ Variant ||--o{ StockLevel : "stocked per location"
26
+ Variant }o--o{ OptionValue : "identified by"
25
27
  OptionType ||--o{ OptionValue : "has many"
26
- Taxon ||--o{ Classification : "has many"
27
- Taxonomy ||--o{ Taxon : "has many"
28
+ Category ||--o{ Category : "nests under"
28
29
 
29
30
  Product {
30
31
  string name
@@ -36,7 +37,7 @@ erDiagram
36
37
 
37
38
  Variant {
38
39
  string sku
39
- boolean is_master
40
+ string barcode
40
41
  decimal weight
41
42
  }
42
43
 
@@ -60,21 +61,26 @@ erDiagram
60
61
  ## Product Attributes
61
62
 
62
63
  | Attribute | Description | Translatable |
63
- |-----------|-------------|:---:|
64
+ |---|---|:---:|
64
65
  | `name` | Product name | Yes |
65
- | `description` | Full product description | Yes |
66
- | `slug` | URL-friendly identifier (e.g., `spree-tote`) | Yes |
67
- | `status` | `draft`, `active`, or `archived`. A marketplace adds `proposed` and `rejected` — see Seller submissions below | No |
68
- | `available_on` | Date the product becomes available for sale | No |
69
- | `discontinue_on` | Date the product is no longer available | No |
70
- | `meta_title` | Custom SEO title | Yes |
71
- | `meta_description` | SEO description | Yes |
72
- | `meta_keywords` | SEO keywords | Yes |
73
- | `purchasable` | Whether the product can be added to cart | No |
74
- | `in_stock` | Whether any variant has stock available | No |
75
- | `price` | Default variant's price in the current currency | No |
76
- | `thumbnail_url` | URL to the product's first image always returned, no expand needed | No |
77
- | `tags` | Array of tag strings for filtering | No |
66
+ | `description` / `description_html` | Description as plain text and as formatted HTML | Yes |
67
+ | `slug` | URL identifier, e.g. `spree-tote` | Yes |
68
+ | `status` | `draft`, `active` or `archived`. A marketplace adds `proposed` and `rejected` — see [Seller submissions](#seller-submissions) | No |
69
+ | `available_on` | When it goes on sale | No |
70
+ | `discontinue_on` | When it comes off | No |
71
+ | `meta_title` / `meta_description` / `meta_keywords` | SEO fields | Yes |
72
+ | `purchasable` | Whether it can be added to a cart | No |
73
+ | `in_stock` | Whether any variant has stock | No |
74
+ | `backorderable` | Whether it can be ordered while out of stock | No |
75
+ | `preorder` / `preorder_ships_at` | Whether it's sold ahead of availability, and when it ships | No |
76
+ | `available` | Whether it's on sale right now, by date and status | No |
77
+ | `price` / `original_price` | The [default variant's](#the-default-variant) price, and its compare-at price | No |
78
+ | `default_variant_id` | Which variant represents the product | No |
79
+ | `variant_count` | How many variants it has | No |
80
+ | `thumbnail_url` | First image — always returned, no expand needed | No |
81
+ | `tags` | Tags, for filtering | No |
82
+
83
+ The Admin API adds the operational fields on top: `product_type_id`, `delivery_profile_id`, `tax_category_id`, `seller_id`, `metadata`, `created_at` / `updated_at` / `deleted_at`.
78
84
 
79
85
  ## Listing Products
80
86
 
@@ -161,18 +167,20 @@ Pass `expand` to include related resources in a single response — see [expand
161
167
 
162
168
  The examples above use the **Store API** (publishable key, read-only, customer-facing). To **create and manage** products, use the [Admin API](../../api-reference/admin-api/introduction.md) — via the [Admin SDK](../sdk/admin/quickstart.md) or the [Spree CLI](../cli/admin-api.md).
163
169
 
164
- A product's purchasable attributes (SKU, prices, stock) live on its **variants**, which you can create inline:
170
+ A product's purchasable attributes (SKU, prices, stock) live on its **variants**, which you can create inline.
171
+
172
+ For a product with options, send the variants:
165
173
 
166
174
 
167
175
  ```typescript Admin SDK
168
176
  import { createAdminClient } from '@spree/admin-sdk'
169
177
 
170
- const client = createAdminClient({
178
+ const adminClient = createAdminClient({
171
179
  baseUrl: 'https://store.example.com',
172
180
  secretKey: 'sk_xxx',
173
181
  })
174
182
 
175
- const product = await client.products.create({
183
+ const product = await adminClient.products.create({
176
184
  name: 'Premium T-Shirt',
177
185
  description: 'Soft, organic cotton.',
178
186
  status: 'active',
@@ -184,7 +192,7 @@ const product = await client.products.create({
184
192
  { name: 'color', value: 'navy' },
185
193
  ],
186
194
  prices: [{ currency: 'USD', amount: '29.99' }],
187
- stock_items: [{ stock_location_id: 'sloc_xxx', count_on_hand: 50 }],
195
+ stock_levels: [{ stock_location_id: 'sloc_xxx', count_on_hand: 50 }],
188
196
  },
189
197
  ],
190
198
  })
@@ -203,6 +211,18 @@ spree api post /products -d '{
203
211
  ```
204
212
 
205
213
 
214
+ For something with no options at all, send prices directly and skip the variants array — Spree forwards them to the product's single variant:
215
+
216
+ ```typescript Admin SDK
217
+ await adminClient.products.create({
218
+ name: 'The Spree Handbook',
219
+ status: 'active',
220
+ prices: [{ currency: 'USD', amount: '19.99' }],
221
+ })
222
+ ```
223
+
224
+ Don't pass both.
225
+
206
226
  Update, clone, or archive a product (deleting soft-deletes it):
207
227
 
208
228
 
@@ -221,7 +241,7 @@ spree api delete /products/prod_xxx
221
241
 
222
242
  > **TIP:** Operating on many products at once? The Admin API has bulk actions — `bulkStatusUpdate`, `bulkAddToCategories`, `bulkAddTags`, `bulkDestroy`, and more. See the [Admin API endpoint index](../../api-reference/admin-api/endpoints.md).
223
243
 
224
- ## Seller submissions
244
+ ## Seller submissions
225
245
 
226
246
  On a marketplace, a seller lists a product but does not publish one. They submit it, and the marketplace decides. This adds two statuses to the three above — both hidden from the storefront, since only `active` is visible.
227
247
 
@@ -398,29 +418,54 @@ curl 'https://api.mystore.com/api/v3/store/products/filters?category_id=ctg_xxx'
398
418
 
399
419
  ## Variants
400
420
 
401
- Variants are the purchasable units of a product. Each variant has its own SKU, price, inventory, and images, and is defined by a unique combination of option values.
421
+ **A product is not the thing you buy a variant is.** The product is the listing; the variant is the actual item with a SKU, a price and stock.
422
+
423
+ That distinction is worth holding onto, because everything purchasable lives on the variant:
402
424
 
403
425
  | Attribute | Description |
404
- |-----------|-------------|
405
- | `sku` | Unique stock keeping unit |
406
- | `barcode` | Barcode (UPC, EAN, etc.) |
426
+ |---|---|
427
+ | `sku` | Stock keeping unit |
428
+ | `barcode` | Barcode UPC, EAN and so on |
407
429
  | `price` | Price in the current currency |
408
- | `original_price` | Compare-at price for showing discounts |
409
- | `weight`, `height`, `width`, `depth` | Dimensions for shipping calculations |
410
- | `in_stock` | Whether stock is available |
411
- | `backorderable` | Whether the variant can be ordered when out of stock |
412
- | `option_values` | The option values that define this variant (e.g., Size: Small, Color: Red) |
430
+ | `original_price` | Compare-at price, for showing a reduction |
431
+ | `weight`, `height`, `width`, `depth` | Used for delivery rates and labels |
432
+ | `in_stock` / `purchasable` | Whether it can be bought right now |
433
+ | `backorderable` | Whether it can be ordered while out of stock |
434
+ | `preorder` / `preorder_ships_at` | Whether it's sold ahead of availability |
435
+ | `option_values` | What distinguishes it — Size: Small, Colour: Red |
436
+ | `options_text` | Those values as one readable string |
437
+ | `track_inventory` | Whether stock is counted at all |
438
+
439
+ Variants also carry the customs attributes — `hs_code`, `country_of_origin`, `customs_description` — used when a parcel crosses a border. See [Fees](fees.md#customs-classification).
440
+
441
+ ### Every product has at least one variant
442
+
443
+ There's no such thing as a product without one. A book with no size or colour still has a single variant holding its SKU, price and stock — you simply never show a picker for it.
444
+
445
+ > **NOTE:** Earlier versions of Spree had a special "master variant" alongside the real ones, which meant every query had to remember to exclude it. **That concept is gone.** A product's variants are all real, all purchasable, and all the same kind of thing.
413
446
 
414
- ### Master Variant
447
+ ### The default variant
415
448
 
416
- Every product has a **master variant** that holds default pricing and inventory. If a product has no option types (e.g., a book with no size/color), the master variant is the only purchasable variant.
449
+ `default_variant_id` names the variant that represents the product the price shown on a listing page, and what "add to cart" means before anyone picks anything.
417
450
 
418
- ### Regular Variants
451
+ ```typescript Store SDK
452
+ const product = await client.products.get('spree-tote')
453
+
454
+ product.default_variant_id // "var_xxx"
455
+ product.price // that variant's price
456
+ product.variant_count // 6
457
+ ```
458
+
459
+ For a single-variant product that's the only variant. For a product with options it's the first one, unless you say otherwise. If the default is ever removed, another is promoted automatically — a product is never left without one.
419
460
 
420
- When a product has option types, each unique combination of option values creates a variant. For example, a T-shirt with sizes (S, M, L) and colors (Red, Green) has 6 variants:
461
+ Products can also carry `buy_box_variant_id`, naming the variant a marketplace has chosen to feature when several sellers offer the same listing.
421
462
 
422
- | SKU | Size | Color |
423
- |-----|------|-------|
463
+ ### Options make variants
464
+
465
+ A product with option types has one variant per combination. A T-shirt in three sizes and two colours is six variants:
466
+
467
+ | SKU | Size | Colour |
468
+ |---|---|---|
424
469
  | `TEE-S-R` | Small | Red |
425
470
  | `TEE-S-G` | Small | Green |
426
471
  | `TEE-M-R` | Medium | Red |
@@ -428,20 +473,18 @@ When a product has option types, each unique combination of option values create
428
473
  | `TEE-L-R` | Large | Red |
429
474
  | `TEE-L-G` | Large | Green |
430
475
 
431
- The product's `default_variant_id` points to the first non-master variant (or the master variant if none exist).
432
-
433
- Add a variant to an existing product via the Admin API (SKU, prices, and stock all live on the variant):
476
+ Adding one to an existing product:
434
477
 
435
478
 
436
479
  ```typescript Admin SDK
437
- const variant = await client.products.variants.create('prod_xxx', {
480
+ await adminClient.products.variants.create('prod_xxx', {
438
481
  sku: 'TEE-L-R',
439
482
  options: [
440
483
  { name: 'size', value: 'Large' },
441
484
  { name: 'color', value: 'Red' },
442
485
  ],
443
486
  prices: [{ currency: 'USD', amount: '24.99' }],
444
- stock_items: [{ stock_location_id: 'sloc_xxx', count_on_hand: 30 }],
487
+ stock_levels: [{ stock_location_id: 'sloc_xxx', count_on_hand: 30 }],
445
488
  })
446
489
  ```
447
490
 
@@ -454,6 +497,8 @@ spree api post /products/prod_xxx/variants -d '{
454
497
  ```
455
498
 
456
499
 
500
+ Options are named by value rather than by ID, so you don't have to look up an option value before creating a variant that uses it.
501
+
457
502
  ## Option Types and Option Values
458
503
 
459
504
  Option types define the axes of variation for a product (e.g., Size, Color, Material). Option values are the specific choices within each type (e.g., Small, Medium, Large).
@@ -518,9 +563,76 @@ spree api post /option_types -d '{
518
563
  ```
519
564
 
520
565
 
566
+ ## Product Types
567
+
568
+ Merchants who sell more than one kind of thing end up repeating themselves. Every pair of shoes needs Size and Colour, belongs under Footwear, and wants a Material field. Every book needs an ISBN and an author.
569
+
570
+ A **product type** captures that once. Creating a product from a type gives it the right option types, the right categories, the right delivery profile, and a form asking for the fields that kind of product actually needs.
571
+
572
+ ```mermaid
573
+ erDiagram
574
+ ProductType ||--o{ Product : "creates"
575
+ ProductType }o--o{ OptionType : "seeds"
576
+ ProductType }o--o{ Category : "seeds"
577
+ ProductType }o--o{ CustomFieldDefinition : "asks for"
578
+ ProductType }o--o| DeliveryProfile : "ships by"
579
+
580
+ ProductType {
581
+ string name
582
+ integer products_count
583
+ }
584
+ ```
585
+
586
+ ```typescript Admin SDK
587
+ const shoes = await adminClient.productTypes.create({
588
+ name: 'Footwear',
589
+ option_type_ids: ['optt_size', 'optt_color'],
590
+ category_ids: ['ctg_footwear'],
591
+ delivery_profile_id: 'dp_standard',
592
+ custom_field_definitions: [
593
+ { id: 'cfdef_material', required: true, sort_order: 0 },
594
+ ],
595
+ })
596
+
597
+ await adminClient.products.create({
598
+ name: 'Trail Runner',
599
+ product_type_id: shoes.id,
600
+ status: 'draft',
601
+ })
602
+ ```
603
+
604
+ ### A type is a template, not a controller
605
+
606
+ This is the part that determines how you should think about them.
607
+
608
+ > **WARNING:** **Editing a product type never rewrites existing products.** Add an option type to Footwear next month and the shoes you created last month are untouched.
609
+
610
+ That's deliberate. A merchant who adds a field to a type is describing what *new* products should look like — not asking Spree to silently restructure a live catalogue, invalidate URLs, or change what customers can pick.
611
+
612
+ So the pieces behave in two distinct ways:
613
+
614
+ | Part of the type | Behaviour |
615
+ |---|---|
616
+ | Option types, categories, delivery profile | **Stamped at creation.** Copied onto the product, then independent |
617
+ | Custom field definitions | **Live by reference.** The form always reflects the type as it is now |
618
+
619
+ Seeding is also **additive** — it adds what's missing and never removes what a product already has. Reassigning a product's type is therefore safe: it seeds the new type's option types and categories alongside whatever was already there.
620
+
621
+ If you *do* want an edited type to reach the products already using it, that's an explicit action:
622
+
623
+ ```typescript Admin SDK
624
+ const { products_count } = await adminClient.productTypes.applyToProducts('pt_xxx')
625
+ ```
626
+
627
+ It runs in the background and is additive like the rest — never a side effect of saving a type.
628
+
629
+ > **INFO:** `required` on a type's custom field is **advisory** — it marks the field in the dashboard but isn't enforced on write, since Spree saves the product and its fields in two steps. Validate in your own tooling if you need it enforced.
630
+
631
+ A type in use can't be deleted; its products would lose the structure they were built from.
632
+
521
633
  ## Media
522
634
 
523
- Media can be attached to the product (via the master variant) or to individual variants. When displaying a product, show the images for the selected variant, falling back to the product-level images.
635
+ Media can be attached to a product or to individual variants. When displaying a product, show the images for the selected variant, falling back to the product's own.
524
636
 
525
637
  ### Thumbnails
526
638
 
@@ -561,7 +673,7 @@ const product = await client.products.get('spree-tote', {
561
673
  expand: ['media', 'variants'],
562
674
  })
563
675
 
564
- // Product-level images (from master variant)
676
+ // The product's own images
565
677
  product.media // [{ original_url: "https://cdn.../tote-front.jpg", position: 1 }, ...]
566
678
 
567
679
  // Each variant has its own thumbnail and media_count
@@ -606,14 +718,19 @@ See the [Pricing](pricing.md) guide for details on Price Lists, Price Rules, and
606
718
 
607
719
  ## Categories
608
720
 
609
- Categories provide a flexible way to organize products into hierarchical trees. Internally, Spree uses Taxonomies (category trees) and Taxons (nodes within those trees), but the Store API exposes them simply as **Categories**.
721
+ There are two ways to group products, and they answer different questions.
722
+
723
+ **Categories** are a hierarchy — the navigation tree a shopper browses. Clothing contains T-Shirts, which contains Long Sleeve. A product can sit in several categories, and each one has a permalink built from its path.
724
+
725
+ **Collections** are flat groupings — "Summer 2025", "Best Sellers", "Under $50". A collection can be curated by hand, or defined by rules so products join and leave it on their own as their price, tags or stock change.
610
726
 
611
- For example:
612
- - **Categories** → Clothing → T-Shirts, Dresses
613
- - **Brands** Nike, Adidas, Puma
614
- - **Collections** Summer 2025, Best Sellers
727
+ | | Categories | Collections |
728
+ |---|---|---|
729
+ | Shape | Nested tree | Flat list |
730
+ | Membership | You assign it | Assigned, or matched by rules |
731
+ | Typical use | Site navigation | Merchandising and campaigns |
615
732
 
616
- Products can belong to multiple categories.
733
+ A brand is usually best modelled as one or the other rather than as a separate concept — a category if you want it in the navigation tree, a collection if it's a landing page.
617
734
 
618
735
 
619
736
  ```typescript Store SDK
@@ -650,7 +767,19 @@ curl 'https://api.mystore.com/api/v3/store/categories/clothing/shirts/products?l
650
767
 
651
768
  > **INFO:** Category `name` and `description` fields are translatable.
652
769
 
653
- ## Publications and Sales Channels
770
+ Collections work the same way from a storefront's point of view:
771
+
772
+ ```typescript Store SDK
773
+ const { data: collections } = await client.collections.list()
774
+
775
+ const { data: products } = await client.collections.products.list('summer-2025', {
776
+ limit: 12,
777
+ })
778
+ ```
779
+
780
+ A rule-based collection is defined once and maintains itself — set it to match everything tagged `sale` and under $50, and products appear and disappear as those facts change. Ordering can be manual or by a rule such as newest first.
781
+
782
+ ## Publications and Sales Channels
654
783
 
655
784
  A product is visible on a [Channel](channels.md) only when a `ProductPublication` record joins the two. Publications carry an optional time window so a product can be scheduled to go live and come down without code or manual toggles.
656
785