@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.
- package/dist/developer/core-concepts/addresses.md +106 -198
- package/dist/developer/core-concepts/architecture.md +97 -126
- package/dist/developer/core-concepts/calculators.md +75 -252
- package/dist/developer/core-concepts/carts.md +1 -1
- package/dist/developer/core-concepts/catalogs.md +140 -0
- package/dist/developer/core-concepts/channels.md +0 -4
- package/dist/developer/core-concepts/commissions.md +253 -0
- package/dist/developer/core-concepts/companies.md +240 -0
- package/dist/developer/core-concepts/customers.md +0 -3
- package/dist/developer/core-concepts/discounts.md +133 -0
- package/dist/developer/core-concepts/events.md +83 -576
- package/dist/developer/core-concepts/fees.md +144 -0
- package/dist/developer/core-concepts/imports-exports.md +105 -679
- package/dist/developer/core-concepts/inventory.md +114 -248
- package/dist/developer/core-concepts/markets.md +9 -12
- package/dist/developer/core-concepts/media.md +127 -16
- package/dist/developer/core-concepts/metafields.md +123 -200
- package/dist/developer/core-concepts/order-totals.md +110 -0
- package/dist/developer/core-concepts/orders.md +1 -1
- package/dist/developer/core-concepts/payments.md +11 -14
- package/dist/developer/core-concepts/pricing.md +11 -13
- package/dist/developer/core-concepts/products.md +191 -62
- package/dist/developer/core-concepts/promotions.md +12 -11
- package/dist/developer/core-concepts/search-filtering.md +2 -4
- package/dist/developer/core-concepts/sellers.md +210 -0
- package/dist/developer/core-concepts/staff-roles.md +56 -23
- package/dist/developer/core-concepts/store-credits-gift-cards.md +0 -3
- package/dist/developer/core-concepts/taxes.md +125 -113
- package/dist/developer/core-concepts/translations.md +61 -68
- package/dist/developer/core-concepts/webhooks.md +25 -59
- package/dist/developer/how-to/custom-promotion.md +3 -3
- package/package.json +1 -1
- package/dist/developer/core-concepts/companies-and-catalogs.md +0 -81
- 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 [
|
|
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
|
|
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
|
|
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
|
-
##
|
|
689
|
+
## Two paths to a completed order
|
|
689
690
|
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
| **
|
|
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
|
-
|
|
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
|
-
###
|
|
184
|
+
### Channel Rule
|
|
187
185
|
|
|
188
|
-
Applies
|
|
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
|
-
|
|
|
219
|
-
| Customer |
|
|
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
|
|
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
|
-
|
|
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
|
|
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 : "
|
|
20
|
-
Product
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Variant
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
66
|
-
| `slug` | URL
|
|
67
|
-
| `status` | `draft`, `active
|
|
68
|
-
| `available_on` |
|
|
69
|
-
| `discontinue_on` |
|
|
70
|
-
| `meta_title` |
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
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
|
|
178
|
+
const adminClient = createAdminClient({
|
|
171
179
|
baseUrl: 'https://store.example.com',
|
|
172
180
|
secretKey: 'sk_xxx',
|
|
173
181
|
})
|
|
174
182
|
|
|
175
|
-
const product = await
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
406
|
-
| `barcode` | Barcode
|
|
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
|
|
409
|
-
| `weight`, `height`, `width`, `depth` |
|
|
410
|
-
| `in_stock` | Whether
|
|
411
|
-
| `backorderable` | Whether
|
|
412
|
-
| `
|
|
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
|
-
###
|
|
447
|
+
### The default variant
|
|
415
448
|
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|