@spree/docs 0.1.229 → 0.1.231
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-reference/store.yaml +78 -78
- package/dist/developer/agentic/agent-skills.md +1 -1
- package/dist/developer/cli/quickstart.md +2 -2
- package/dist/developer/core-concepts/addresses.md +13 -0
- package/dist/developer/core-concepts/calculators.md +6 -1
- package/dist/developer/core-concepts/carts.md +19 -1
- package/dist/developer/core-concepts/catalogs.md +51 -2
- package/dist/developer/core-concepts/channels.md +20 -1
- package/dist/developer/core-concepts/commissions.md +70 -1
- package/dist/developer/core-concepts/companies.md +74 -0
- package/dist/developer/core-concepts/customers.md +17 -5
- package/dist/developer/core-concepts/data-privacy.md +20 -7
- package/dist/developer/core-concepts/delivery-setup.md +57 -0
- package/dist/developer/core-concepts/discounts.md +7 -0
- package/dist/developer/core-concepts/events.md +13 -7
- package/dist/developer/core-concepts/fees.md +20 -0
- package/dist/developer/core-concepts/fulfillments.md +3 -3
- package/dist/developer/core-concepts/imports-exports.md +21 -5
- package/dist/developer/core-concepts/inventory.md +73 -1
- package/dist/developer/core-concepts/markets.md +5 -6
- package/dist/developer/core-concepts/media.md +66 -1
- package/dist/developer/core-concepts/metafields.md +1 -1
- package/dist/developer/core-concepts/order-totals.md +18 -1
- package/dist/developer/core-concepts/orders.md +2 -1
- package/dist/developer/core-concepts/payments.md +100 -20
- package/dist/developer/core-concepts/pricing.md +13 -8
- package/dist/developer/core-concepts/products.md +52 -1
- package/dist/developer/core-concepts/reporting.md +5 -5
- package/dist/developer/core-concepts/returns-exchanges-claims.md +41 -2
- package/dist/developer/core-concepts/search-filtering.md +1 -2
- package/dist/developer/core-concepts/sellers.md +109 -2
- package/dist/developer/core-concepts/slugs.md +1 -1
- package/dist/developer/core-concepts/staff-roles.md +23 -3
- package/dist/developer/core-concepts/stores.md +21 -0
- package/dist/developer/core-concepts/taxes.md +43 -0
- package/dist/developer/core-concepts/translations.md +9 -0
- package/dist/developer/core-concepts/webhooks.md +45 -3
- package/dist/developer/create-spree-app/quickstart.md +10 -10
- package/dist/developer/dashboard/deployment.md +4 -4
- package/dist/developer/dashboard/recipes/attribute-end-to-end.md +1 -1
- package/dist/developer/deployment/docker.md +2 -2
- package/dist/developer/how-to/custom-promotion.md +33 -13
- package/dist/developer/multi-tenant/quickstart.md +6 -6
- package/dist/developer/storefront/nextjs/testing.md +2 -2
- package/package.json +1 -1
|
@@ -21,6 +21,8 @@ erDiagram
|
|
|
21
21
|
Catalog ||--o{ CatalogProduct : "assortment"
|
|
22
22
|
Catalog ||--o{ CatalogAssignment : "audiences"
|
|
23
23
|
Catalog ||--o| PriceList : "owns (optional)"
|
|
24
|
+
Catalog ||--o{ CatalogQuantityRule : "per-SKU terms"
|
|
25
|
+
Catalog ||--o{ CatalogOrderMinimum : "cart minimum"
|
|
24
26
|
CatalogAssignment }o--|| CustomerGroup : "one of"
|
|
25
27
|
CatalogAssignment }o--|| Company : "one of"
|
|
26
28
|
|
|
@@ -28,6 +30,8 @@ erDiagram
|
|
|
28
30
|
string name
|
|
29
31
|
boolean active
|
|
30
32
|
integer position
|
|
33
|
+
integer minimum_order_quantity
|
|
34
|
+
integer order_multiple
|
|
31
35
|
}
|
|
32
36
|
```
|
|
33
37
|
|
|
@@ -48,14 +52,29 @@ That's worth pausing on, because it's the one behaviour that surprises people: a
|
|
|
48
52
|
|
|
49
53
|
## Creating one
|
|
50
54
|
|
|
55
|
+
|
|
51
56
|
```typescript Admin SDK
|
|
52
57
|
const catalog = await adminClient.catalogs.create({
|
|
53
58
|
name: 'Wholesale',
|
|
54
|
-
|
|
55
|
-
active: true,
|
|
59
|
+
price_list: { name: 'Wholesale pricing' },
|
|
56
60
|
})
|
|
61
|
+
|
|
62
|
+
await adminClient.catalogs.activate(catalog.id)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```bash cURL
|
|
66
|
+
# Create the agreement with its pricing inline
|
|
67
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs' \
|
|
68
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
69
|
+
-H 'Content-Type: application/json' \
|
|
70
|
+
-d '{ "name": "Wholesale", "price_list": { "name": "Wholesale pricing" } }'
|
|
71
|
+
|
|
72
|
+
# Catalogs are born inactive — going live is its own act
|
|
73
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/activate' \
|
|
74
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
57
75
|
```
|
|
58
76
|
|
|
77
|
+
|
|
59
78
|
The price list is optional. A catalog with an assortment and no price list restricts the range at normal prices; a catalog with a price list and no assortment adjusts prices without hiding anything.
|
|
60
79
|
|
|
61
80
|
A price list is either **standalone** (matched by its own rules) or **owned by exactly one catalog**. A catalog creates the list it prices through, so the two are configured together on the catalog page — an owned list never appears in the price-lists index, since it has no rules or audience of its own.
|
|
@@ -64,6 +83,7 @@ Removing a catalog's pricing (`price_list: null`), or deleting the catalog itsel
|
|
|
64
83
|
|
|
65
84
|
### Filling the assortment
|
|
66
85
|
|
|
86
|
+
|
|
67
87
|
```typescript Admin SDK
|
|
68
88
|
// Add products (a bare array of IDs)
|
|
69
89
|
await adminClient.catalogs.products.create(catalog.id, ['prod_xxx', 'prod_yyy'])
|
|
@@ -73,14 +93,34 @@ const { data: products } = await adminClient.catalogs.products.list(catalog.id)
|
|
|
73
93
|
await adminClient.catalogs.products.delete(catalog.id, 'prod_xxx')
|
|
74
94
|
```
|
|
75
95
|
|
|
96
|
+
```bash cURL
|
|
97
|
+
# Add products to the assortment
|
|
98
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/products' \
|
|
99
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
100
|
+
-H 'Content-Type: application/json' \
|
|
101
|
+
-d '{ "product_ids": ["prod_xxx", "prod_yyy"] }'
|
|
102
|
+
|
|
103
|
+
# List them
|
|
104
|
+
curl 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/products' \
|
|
105
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
|
|
76
109
|
Membership is all a catalog holds — it decides *what* a buyer sees, never the order they see it in. Presentation order stays with categories and collections.
|
|
77
110
|
|
|
78
111
|
When a catalog should restrict to exactly what its price list covers, there's a shortcut that copies those products in rather than making someone add them by hand:
|
|
79
112
|
|
|
113
|
+
|
|
80
114
|
```typescript Admin SDK
|
|
81
115
|
const { added_count } = await adminClient.catalogs.importProducts(catalog.id)
|
|
82
116
|
```
|
|
83
117
|
|
|
118
|
+
```bash cURL
|
|
119
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/import_products' \
|
|
120
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
|
|
84
124
|
## Choosing the audience
|
|
85
125
|
|
|
86
126
|
A catalog is assigned to a buyer audience:
|
|
@@ -92,6 +132,7 @@ A catalog is assigned to a buyer audience:
|
|
|
92
132
|
|
|
93
133
|
A [Channel](channels.md) is not assigned — it names its catalog directly via `default_catalog_id`, which applies to everyone buying through it when nothing narrower does.
|
|
94
134
|
|
|
135
|
+
|
|
95
136
|
```typescript Admin SDK
|
|
96
137
|
await adminClient.catalogs.assign(catalog.id, {
|
|
97
138
|
assignable_type: 'customer_group',
|
|
@@ -99,6 +140,14 @@ await adminClient.catalogs.assign(catalog.id, {
|
|
|
99
140
|
})
|
|
100
141
|
```
|
|
101
142
|
|
|
143
|
+
```bash cURL
|
|
144
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/catalogs/cat_xxx/assign' \
|
|
145
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
146
|
+
-H 'Content-Type: application/json' \
|
|
147
|
+
-d '{ "assignable_type": "customer_group", "assignable_id": "cgrp_xxx" }'
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
|
|
102
151
|
> **NOTE:** **A company assignment covers the whole subtree.** Assign the group-wide catalog once at the root of a [company tree](companies.md) and every division below inherits it — no re-assigning per branch. A branch can still add its own catalog on top.
|
|
103
152
|
|
|
104
153
|
## What a shopper ends up seeing
|
|
@@ -9,6 +9,25 @@ Channels segment a single [Store](stores.md) into distinct selling surfaces. A c
|
|
|
9
9
|
|
|
10
10
|
Every store ships with one default channel named *Online Store*. You can add more from **Settings → Sales channels** in the admin dashboard.
|
|
11
11
|
|
|
12
|
+
```mermaid
|
|
13
|
+
erDiagram
|
|
14
|
+
Store ||--o{ Channel : "has many"
|
|
15
|
+
Channel ||--o{ ProductPublication : "publishes"
|
|
16
|
+
ProductPublication }o--|| Product : "lists"
|
|
17
|
+
Channel ||--o{ Order : "attributes"
|
|
18
|
+
Channel ||--o{ OrderRoutingRule : "routes by"
|
|
19
|
+
Channel ||--o{ ChannelStockLocation : "serves from"
|
|
20
|
+
Channel }o--o| Catalog : "default assortment"
|
|
21
|
+
Channel ||--o{ ApiKey : "authenticates"
|
|
22
|
+
|
|
23
|
+
Channel {
|
|
24
|
+
string name
|
|
25
|
+
string code
|
|
26
|
+
boolean active
|
|
27
|
+
boolean default
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
12
31
|
## Channel Attributes
|
|
13
32
|
|
|
14
33
|
| Attribute | Description | Example |
|
|
@@ -206,7 +225,7 @@ The write contract is **full-set**: the array represents the complete desired st
|
|
|
206
225
|
|
|
207
226
|
- **Dashboard** — new products are auto-published on the store's default channel. The merchant can untick channels via the Publishing card post-create.
|
|
208
227
|
- **Admin API** — new products are **not** auto-published. The caller supplies `product_publications: [{ channel_id }]` on create, or calls `POST /admin/channels/:id/add_products` afterwards.
|
|
209
|
-
- **Sample data** (`
|
|
228
|
+
- **Sample data** (`spree sample-data`) — all loaded products are explicitly published on the default channel.
|
|
210
229
|
|
|
211
230
|
## Related Documentation
|
|
212
231
|
|
|
@@ -31,6 +31,7 @@ flowchart LR
|
|
|
31
31
|
|
|
32
32
|
A rate says what to charge. It's either a percentage of the sale or a flat amount.
|
|
33
33
|
|
|
34
|
+
|
|
34
35
|
```typescript Admin SDK
|
|
35
36
|
await adminClient.commissionRates.create({
|
|
36
37
|
name: 'Electronics',
|
|
@@ -41,6 +42,20 @@ await adminClient.commissionRates.create({
|
|
|
41
42
|
})
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
```bash cURL
|
|
46
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/commission_rates' \
|
|
47
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
48
|
+
-H 'Content-Type: application/json' \
|
|
49
|
+
-d '{
|
|
50
|
+
"name": "Electronics",
|
|
51
|
+
"code": "electronics",
|
|
52
|
+
"kind": "percentage",
|
|
53
|
+
"value": "12.5",
|
|
54
|
+
"enabled": true
|
|
55
|
+
}'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
|
|
44
59
|
| Attribute | Description |
|
|
45
60
|
|---|---|
|
|
46
61
|
| `name` | What operators call it |
|
|
@@ -84,6 +99,7 @@ Rules narrow when a rate applies. A rate can hold several, and **all of them mus
|
|
|
84
99
|
|
|
85
100
|
So "electronics **and** these three sellers" is two rules on one rate.
|
|
86
101
|
|
|
102
|
+
|
|
87
103
|
```typescript Admin SDK
|
|
88
104
|
// Every rule kind, with the schema describing its settings — so an editor
|
|
89
105
|
// is built from what this marketplace actually has, not a hardcoded list
|
|
@@ -97,6 +113,23 @@ await adminClient.commissionRates.update('crate_xxx', {
|
|
|
97
113
|
})
|
|
98
114
|
```
|
|
99
115
|
|
|
116
|
+
```bash cURL
|
|
117
|
+
# Discover the rule kinds this marketplace has
|
|
118
|
+
curl 'https://api.mystore.com/api/v3/admin/commission_rates/rule_types' \
|
|
119
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
120
|
+
|
|
121
|
+
# Attach rules to a rate
|
|
122
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/commission_rates/crate_xxx' \
|
|
123
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
124
|
+
-H 'Content-Type: application/json' \
|
|
125
|
+
-d '{
|
|
126
|
+
"rules": [
|
|
127
|
+
{ "type": "category_rule", "preferences": { "category_ids": ["ctg_xxx"] } }
|
|
128
|
+
]
|
|
129
|
+
}'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
|
|
100
133
|
Rules are replaced wholesale on update — send the full set you want.
|
|
101
134
|
|
|
102
135
|
### The four rule types
|
|
@@ -164,6 +197,7 @@ Discounts come off either way. Commission is charged on what the customer actual
|
|
|
164
197
|
|
|
165
198
|
Money is stated per currency, never converted:
|
|
166
199
|
|
|
200
|
+
|
|
167
201
|
```typescript Admin SDK
|
|
168
202
|
// A percentage, with a floor and a cap in each currency
|
|
169
203
|
await adminClient.commissionRates.create({
|
|
@@ -185,6 +219,34 @@ await adminClient.commissionRates.create({
|
|
|
185
219
|
})
|
|
186
220
|
```
|
|
187
221
|
|
|
222
|
+
```bash cURL
|
|
223
|
+
# A percentage, with a floor and a cap in each currency
|
|
224
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/commission_rates' \
|
|
225
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
226
|
+
-H 'Content-Type: application/json' \
|
|
227
|
+
-d '{
|
|
228
|
+
"name": "Standard",
|
|
229
|
+
"kind": "percentage",
|
|
230
|
+
"value": 10,
|
|
231
|
+
"bounds": {
|
|
232
|
+
"USD": { "min_amount": 1, "max_amount": 50 },
|
|
233
|
+
"EUR": { "min_amount": 1, "max_amount": 45 }
|
|
234
|
+
}
|
|
235
|
+
}'
|
|
236
|
+
|
|
237
|
+
# A flat fee, stated per currency
|
|
238
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/commission_rates' \
|
|
239
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
240
|
+
-H 'Content-Type: application/json' \
|
|
241
|
+
-d '{
|
|
242
|
+
"name": "Listing fee",
|
|
243
|
+
"kind": "fixed",
|
|
244
|
+
"value": 0,
|
|
245
|
+
"amounts": { "USD": "5.00", "GBP": "4.00" }
|
|
246
|
+
}'
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
|
|
188
250
|
- **`amounts`** — what a flat fee charges, per currency
|
|
189
251
|
- **`bounds`** — the floor and cap a percentage charges within, per currency
|
|
190
252
|
|
|
@@ -229,12 +291,19 @@ When an order is placed, the resolved rate is applied and the result frozen as a
|
|
|
229
291
|
| `line_item_id` / `fulfillment_id` | What was commissioned — exactly one |
|
|
230
292
|
| `commission_rate_id` | The rate, if it still exists |
|
|
231
293
|
|
|
294
|
+
|
|
232
295
|
```typescript Admin SDK
|
|
233
296
|
const { data: lines } = await adminClient.commissionLines.list({
|
|
234
|
-
|
|
297
|
+
seller_id_eq: 'sel_xxx',
|
|
235
298
|
})
|
|
236
299
|
```
|
|
237
300
|
|
|
301
|
+
```bash cURL
|
|
302
|
+
curl 'https://api.mystore.com/api/v3/admin/commission_lines?q[seller_id_eq]=sel_xxx' \
|
|
303
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
|
|
238
307
|
**Every field is a snapshot.** Editing the rate afterwards changes nothing here, and neither does deleting it — a retired rate is soft-deleted precisely so "which rate charged this" stays answerable.
|
|
239
308
|
|
|
240
309
|
There is **no write path**. Lines are read-only in the API, and correcting a charge means recording a reversal rather than editing history — the discipline any ledger needs.
|
|
@@ -59,6 +59,7 @@ So a buyer attached to "Acme Europe" can act for Sales and Operations beneath it
|
|
|
59
59
|
|
|
60
60
|
This is why authorization always asks "does this person have standing on this node *or any of its ancestors*", never "is this person a member of exactly this node".
|
|
61
61
|
|
|
62
|
+
|
|
62
63
|
```typescript Store SDK
|
|
63
64
|
// Which companies can this customer act for?
|
|
64
65
|
const { data: memberships } = await client.account.companies()
|
|
@@ -69,6 +70,13 @@ memberships.forEach((m) => {
|
|
|
69
70
|
})
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
```bash cURL
|
|
74
|
+
curl 'https://api.mystore.com/api/v3/store/account/companies' \
|
|
75
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
76
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
72
80
|
> **NOTE:** **In open source, every member can do everything within their standing** — buy, see the subtree's orders, and manage addresses and members. There are no company roles; the `role` label on a membership is cosmetic.
|
|
73
81
|
>
|
|
74
82
|
> To restrict what individual people may do, see [company governance](#company-governance) below.
|
|
@@ -77,12 +85,22 @@ memberships.forEach((m) => {
|
|
|
77
85
|
|
|
78
86
|
Both the dashboard and the storefront add members the same way — by email:
|
|
79
87
|
|
|
88
|
+
|
|
80
89
|
```typescript Store SDK
|
|
81
90
|
const result = await client.companies.members.create('comp_xxx', {
|
|
82
91
|
customer_email: 'buyer@acme.com',
|
|
83
92
|
})
|
|
84
93
|
```
|
|
85
94
|
|
|
95
|
+
```bash cURL
|
|
96
|
+
curl -X POST 'https://api.mystore.com/api/v3/store/companies/comp_xxx/members' \
|
|
97
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
98
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT' \
|
|
99
|
+
-H 'Content-Type: application/json' \
|
|
100
|
+
-d '{ "customer_email": "buyer@acme.com" }'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
|
|
86
104
|
What comes back depends on whether that email is already a customer:
|
|
87
105
|
|
|
88
106
|
- **An existing customer** becomes a member straight away.
|
|
@@ -92,6 +110,7 @@ You can tell which by the ID prefix — `cmem_` for a membership, `cinv_` for an
|
|
|
92
110
|
|
|
93
111
|
Accepting an invitation either signs an existing customer in, or registers a new account with the invited email. Either way it ends as a membership:
|
|
94
112
|
|
|
113
|
+
|
|
95
114
|
```typescript Store SDK
|
|
96
115
|
// The token comes from the invitation email — no sign-in needed to read it
|
|
97
116
|
const invitation = await client.companyInvitations.lookup(token)
|
|
@@ -104,31 +123,60 @@ await client.companyInvitations.accept(token, {
|
|
|
104
123
|
})
|
|
105
124
|
```
|
|
106
125
|
|
|
126
|
+
```bash cURL
|
|
127
|
+
# Reading an invitation needs no sign-in — just the emailed token
|
|
128
|
+
curl 'https://api.mystore.com/api/v3/store/company_invitations/TOKEN' \
|
|
129
|
+
-H 'X-Spree-API-Key: pk_xxx'
|
|
130
|
+
|
|
131
|
+
curl -X POST 'https://api.mystore.com/api/v3/store/company_invitations/TOKEN/accept' \
|
|
132
|
+
-H 'X-Spree-API-Key: pk_xxx'
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
|
|
107
136
|
Memberships are always active and always backed by a real customer. Anything still waiting lives on the invitation, so there's no such thing as a half-active member.
|
|
108
137
|
|
|
109
138
|
## Buying for a company
|
|
110
139
|
|
|
111
140
|
A cart and an order can name a company. That's what turns a personal purchase into a company one — it decides which addresses are on offer, whose tax registration applies, and who else will see the order.
|
|
112
141
|
|
|
142
|
+
|
|
113
143
|
```typescript Store SDK
|
|
114
144
|
await client.carts.update(cartId, { company_id: 'comp_xxx' })
|
|
115
145
|
```
|
|
116
146
|
|
|
147
|
+
```bash cURL
|
|
148
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/store/carts/cart_xxx' \
|
|
149
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
150
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT' \
|
|
151
|
+
-H 'Content-Type: application/json' \
|
|
152
|
+
-d '{ "company_id": "comp_xxx" }'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
|
|
117
156
|
A buyer with exactly one membership doesn't need to choose; it resolves on its own. A buyer who belongs to several names the node — and it must be one they have standing on.
|
|
118
157
|
|
|
119
158
|
The company is **frozen onto the order** at completion, like the addresses and prices. Reorganize the tree next year and last year's order still explains itself.
|
|
120
159
|
|
|
160
|
+
|
|
121
161
|
```typescript Store SDK
|
|
122
162
|
// Everything anyone in the subtree has bought
|
|
123
163
|
const { data: orders } = await client.companies.orders.list('comp_xxx')
|
|
124
164
|
```
|
|
125
165
|
|
|
166
|
+
```bash cURL
|
|
167
|
+
curl 'https://api.mystore.com/api/v3/store/companies/comp_xxx/orders' \
|
|
168
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
169
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
|
|
126
173
|
That subtree rollup is the feature a finance team actually asks for: one place showing what the whole organization spent, without chasing individual accounts.
|
|
127
174
|
|
|
128
175
|
## The shared address book
|
|
129
176
|
|
|
130
177
|
A company keeps its own addresses — ship-to sites, a billing address — separate from any individual's. They're labelled, and one of each kind can be the default.
|
|
131
178
|
|
|
179
|
+
|
|
132
180
|
```typescript Store SDK
|
|
133
181
|
await client.companies.addresses.create('comp_xxx', {
|
|
134
182
|
label: 'Northern Warehouse',
|
|
@@ -142,12 +190,22 @@ await client.companies.addresses.create('comp_xxx', {
|
|
|
142
190
|
})
|
|
143
191
|
```
|
|
144
192
|
|
|
193
|
+
```bash cURL
|
|
194
|
+
curl -X POST 'https://api.mystore.com/api/v3/store/companies/comp_xxx/addresses' \
|
|
195
|
+
-H 'X-Spree-API-Key: pk_xxx' \
|
|
196
|
+
-H 'Authorization: Bearer $CUSTOMER_JWT' \
|
|
197
|
+
-H 'Content-Type: application/json' \
|
|
198
|
+
-d '{ "label": "Northern Warehouse", "first_name": "Goods", "last_name": "Inwards" }'
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
|
|
145
202
|
Note that a delivery site is just an address. Ten warehouses do not mean ten company nodes — nodes exist for organizational and legal structure, addresses for where things go.
|
|
146
203
|
|
|
147
204
|
## Tax
|
|
148
205
|
|
|
149
206
|
Tax registrations and exemption certificates live **only on legal entities**, and a purchase resolves tax through the node's nearest `company` ancestor.
|
|
150
207
|
|
|
208
|
+
|
|
151
209
|
```typescript Admin SDK
|
|
152
210
|
await adminClient.companies.taxIdentifiers.create('comp_xxx', {
|
|
153
211
|
kind: 'eu_vat',
|
|
@@ -158,6 +216,14 @@ await adminClient.companies.taxIdentifiers.create('comp_xxx', {
|
|
|
158
216
|
await adminClient.companies.taxIdentifiers.validate('comp_xxx', 'txid_xxx')
|
|
159
217
|
```
|
|
160
218
|
|
|
219
|
+
```bash cURL
|
|
220
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/companies/comp_xxx/tax_identifiers' \
|
|
221
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
222
|
+
-H 'Content-Type: application/json' \
|
|
223
|
+
-d '{ "kind": "eu_vat", "value": "NL123456789B01" }'
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
|
|
161
227
|
> **WARNING:** The walk stops at the first `company` node, **whether or not it holds a registration**. A subsidiary with no VAT number of its own therefore has none — it never quietly borrows its parent's, because in most jurisdictions that would be a false declaration.
|
|
162
228
|
|
|
163
229
|
Exemption certificates hang off a legal entity and are scoped to the country or state that issued them. Check whether a certificate is `active` rather than reading its status — a verified certificate stops exempting once it expires, and `active` accounts for that.
|
|
@@ -216,6 +282,7 @@ Payment terms and net invoicing, quotes, and a packaged buyer portal are on the
|
|
|
216
282
|
|
|
217
283
|
## Managing companies as staff
|
|
218
284
|
|
|
285
|
+
|
|
219
286
|
```typescript Admin SDK
|
|
220
287
|
// Roots only
|
|
221
288
|
const { data: roots } = await adminClient.companies.list({ parent_id_null: 1 })
|
|
@@ -230,6 +297,13 @@ const division = await adminClient.companies.create({
|
|
|
230
297
|
})
|
|
231
298
|
```
|
|
232
299
|
|
|
300
|
+
```bash cURL
|
|
301
|
+
# Roots only
|
|
302
|
+
curl 'https://api.mystore.com/api/v3/admin/companies?q[parent_id_null]=1' \
|
|
303
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
|
|
233
307
|
Moving a branch is an update with a new `parent_id`. Deleting a node removes its whole subtree, and is refused outright once any order exists beneath it — history doesn't get deleted to tidy up an org chart.
|
|
234
308
|
|
|
235
309
|
Each row carries `children_count` and `members_count`. `children_count` is the node's direct sub-units. `members_count` is the number of distinct customers holding a membership on the node or on any node below it, so somebody who is a member of both a parent and one of its sub-units is counted once rather than twice.
|
|
@@ -10,28 +10,40 @@ Customers interact with your store through the Store API. They can register, log
|
|
|
10
10
|
```mermaid
|
|
11
11
|
erDiagram
|
|
12
12
|
Customer ||--o{ Order : "places"
|
|
13
|
-
Customer ||--o{ Address : "
|
|
13
|
+
Customer ||--o{ Address : "address book"
|
|
14
|
+
Customer ||--o| Address : "default ship/bill"
|
|
14
15
|
Customer ||--o{ Wishlist : "has many"
|
|
15
16
|
Customer ||--o{ StoreCredit : "has many"
|
|
16
|
-
Customer ||--o{
|
|
17
|
+
Customer ||--o{ GiftCard : "holds"
|
|
18
|
+
Customer ||--o{ CompanyMembership : "buys for"
|
|
19
|
+
CompanyMembership }o--|| Company : "belongs to"
|
|
20
|
+
Customer }o--o{ CustomerGroup : "segmented by"
|
|
17
21
|
|
|
18
22
|
Customer {
|
|
19
|
-
string id
|
|
20
23
|
string email
|
|
21
24
|
string first_name
|
|
22
25
|
string last_name
|
|
26
|
+
string phone
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
Order {
|
|
26
30
|
string number
|
|
27
|
-
string
|
|
31
|
+
string payment_status
|
|
32
|
+
string fulfillment_status
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
Address {
|
|
36
|
+
string label
|
|
31
37
|
string first_name
|
|
32
38
|
string last_name
|
|
39
|
+
string company
|
|
33
40
|
string address1
|
|
41
|
+
string address2
|
|
34
42
|
string city
|
|
43
|
+
string postal_code
|
|
44
|
+
string state_code
|
|
45
|
+
string country_code
|
|
46
|
+
string phone
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
Wishlist {
|
|
@@ -71,7 +83,7 @@ curl -X POST 'https://api.mystore.com/api/v3/store/customers' \
|
|
|
71
83
|
|
|
72
84
|
> **NOTE:** The Store API flow above is **customer self-registration** — the customer sets a password and gets a JWT back. To [create a customer record from the back office](../sdk/admin/resources.md) (importers, CRM sync, phone orders), use the [Admin SDK](../sdk/admin/quickstart.md) instead. No password is set, so it's a managed record rather than a sign-up:
|
|
73
85
|
>
|
|
74
|
-
> ```typescript
|
|
86
|
+
> ```typescript Admin SDK
|
|
75
87
|
import { createAdminClient } from '@spree/admin-sdk'
|
|
76
88
|
|
|
77
89
|
const client = createAdminClient({
|
|
@@ -19,7 +19,7 @@ A person has the right to a copy of their data and the right to have it
|
|
|
19
19
|
erased. Both arrive as a `Spree::DataRequest`, which records that the request
|
|
20
20
|
was made and how it was answered.
|
|
21
21
|
|
|
22
|
-
```
|
|
22
|
+
```http
|
|
23
23
|
POST /api/v3/store/customers/me/data_requests { "kind": "access" }
|
|
24
24
|
POST /api/v3/store/customers/me/data_requests { "kind": "erasure", "current_password": "..." }
|
|
25
25
|
```
|
|
@@ -37,7 +37,7 @@ Most requests never reach the storefront. They arrive as email to the
|
|
|
37
37
|
merchant, often from someone who can no longer sign in, so the same two
|
|
38
38
|
actions exist in the admin:
|
|
39
39
|
|
|
40
|
-
```
|
|
40
|
+
```http
|
|
41
41
|
GET /api/v3/admin/customers/:id/export
|
|
42
42
|
POST /api/v3/admin/customers/:id/anonymize
|
|
43
43
|
```
|
|
@@ -69,7 +69,7 @@ A `customer.anonymized` event is published when it completes.
|
|
|
69
69
|
Spree exports what Spree knows. If your application holds data of its own — a
|
|
70
70
|
loyalty ledger, support history — add it through the hook:
|
|
71
71
|
|
|
72
|
-
```ruby
|
|
72
|
+
```ruby server/config/initializers/spree.rb
|
|
73
73
|
Spree.hooks.register('data_requests.fulfill.extend_payload', 'MyStore::LoyaltyExport')
|
|
74
74
|
|
|
75
75
|
class MyStore::LoyaltyExport
|
|
@@ -85,7 +85,7 @@ drop what Spree contributes.
|
|
|
85
85
|
You can also veto an erasure — an open dispute, a fraud investigation, a legal
|
|
86
86
|
hold:
|
|
87
87
|
|
|
88
|
-
```ruby
|
|
88
|
+
```ruby server/config/initializers/spree.rb
|
|
89
89
|
Spree.hooks.register('customers.anonymize.validate', 'MyStore::LegalHold')
|
|
90
90
|
|
|
91
91
|
class MyStore::LegalHold
|
|
@@ -121,7 +121,7 @@ The Omnibus Directive requires that an announced price reduction shows the
|
|
|
121
121
|
lowest price of the previous 30 days. Spree records every base price change in
|
|
122
122
|
`Spree::PriceHistory` and exposes the lowest prior price on request:
|
|
123
123
|
|
|
124
|
-
```
|
|
124
|
+
```http
|
|
125
125
|
GET /api/v3/store/products/:id?expand=prior_price
|
|
126
126
|
```
|
|
127
127
|
|
|
@@ -129,16 +129,22 @@ Recording is on by default and controlled per store with
|
|
|
129
129
|
`track_price_history`, since a non-EU store has no use for it. Retention
|
|
130
130
|
defaults to 30 days via `price_history_retention_days`; prune with:
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
|
|
133
|
+
```bash Spree CLI (Docker)
|
|
134
|
+
spree rake spree:price_history:prune
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```bash Without Spree CLI
|
|
133
138
|
bundle exec rake spree:price_history:prune
|
|
134
139
|
```
|
|
135
140
|
|
|
141
|
+
|
|
136
142
|
## Right of withdrawal
|
|
137
143
|
|
|
138
144
|
EU buyers may withdraw from a distance sale within 14 days of receiving the
|
|
139
145
|
goods. Orders expose the deadline:
|
|
140
146
|
|
|
141
|
-
```json
|
|
147
|
+
```json Response
|
|
142
148
|
{ "withdrawal_period_ends_at": "2026-09-20T10:30:00Z", "within_withdrawal_period": true }
|
|
143
149
|
```
|
|
144
150
|
|
|
@@ -150,3 +156,10 @@ goodwill, while withdrawal is a statutory right running from delivery.
|
|
|
150
156
|
The deadline is computed on read from the order's latest delivery, falling back
|
|
151
157
|
to completion while nothing has been delivered. A stored copy would be wrong
|
|
152
158
|
the moment a parcel arrived.
|
|
159
|
+
|
|
160
|
+
## Related
|
|
161
|
+
|
|
162
|
+
- [Pricing](pricing.md) — price history and the `prior_price` an Omnibus display needs
|
|
163
|
+
- [Customers](customers.md) — the account an export or erasure acts on
|
|
164
|
+
- [Markets](markets.md) — where `withdrawal_period_days` is set, since the right is regional law
|
|
165
|
+
- [Events](events.md) — subscribing to erasure and data-request activity
|
|
@@ -65,6 +65,7 @@ That is how a store with a refrigerated warehouse keeps chilled goods from
|
|
|
65
65
|
being picked at a dry one: order routing only allocates from locations the
|
|
66
66
|
profile covers.
|
|
67
67
|
|
|
68
|
+
|
|
68
69
|
```typescript Admin SDK
|
|
69
70
|
// What profiles the store has
|
|
70
71
|
const profiles = await adminClient.deliveryProfiles.list()
|
|
@@ -75,6 +76,19 @@ await adminClient.products.update('prod_xxx', {
|
|
|
75
76
|
})
|
|
76
77
|
```
|
|
77
78
|
|
|
79
|
+
```bash cURL
|
|
80
|
+
# What profiles the store has
|
|
81
|
+
curl 'https://api.mystore.com/api/v3/admin/delivery_profiles' \
|
|
82
|
+
-H 'X-Spree-API-Key: sk_xxx'
|
|
83
|
+
|
|
84
|
+
# Ship a product as a download
|
|
85
|
+
curl -X PATCH 'https://api.mystore.com/api/v3/admin/products/prod_xxx' \
|
|
86
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
87
|
+
-H 'Content-Type: application/json' \
|
|
88
|
+
-d '{ "delivery_profile_id": "dp_digital" }'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
|
|
78
92
|
> **NOTE:** Profiles are a template at creation time, not a live binding: changing a
|
|
79
93
|
> profile does not rewrite products already assigned to it. Reassign a product
|
|
80
94
|
> explicitly when it should ship differently.
|
|
@@ -85,6 +99,7 @@ A **delivery method** is what the customer picks — "Standard", "Express", "Col
|
|
|
85
99
|
|
|
86
100
|
Zones match on country and state, and also on postal code prefixes and ranges, so you can offer same-day delivery to a handful of city postcodes without listing every one:
|
|
87
101
|
|
|
102
|
+
|
|
88
103
|
```typescript Admin SDK
|
|
89
104
|
await adminClient.deliveryZones.create({
|
|
90
105
|
name: 'London same-day',
|
|
@@ -95,6 +110,19 @@ await adminClient.deliveryZones.create({
|
|
|
95
110
|
})
|
|
96
111
|
```
|
|
97
112
|
|
|
113
|
+
```bash cURL
|
|
114
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/delivery_zones' \
|
|
115
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
116
|
+
-H 'Content-Type: application/json' \
|
|
117
|
+
-d '{
|
|
118
|
+
"name": "London same-day",
|
|
119
|
+
"members": [
|
|
120
|
+
{ "member_type": "postal_code", "country_code": "GB", "postal_code_prefix": "SW1" }
|
|
121
|
+
]
|
|
122
|
+
}'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
|
|
98
126
|
A zone member is either a whole country, a state, or a postal code prefix or range.
|
|
99
127
|
|
|
100
128
|
> **NOTE:** Delivery zones cover delivery only. Tax is handled separately — see [Taxes](taxes.md).
|
|
@@ -129,6 +157,7 @@ Package types are that vocabulary: the box parcels ship in, and for wholesale,
|
|
|
129
157
|
the cartons products are packed into and the pallets or containers an order
|
|
130
158
|
leaves on.
|
|
131
159
|
|
|
160
|
+
|
|
132
161
|
```typescript Admin SDK
|
|
133
162
|
await adminClient.packageTypes.create({
|
|
134
163
|
name: 'Standard mailer',
|
|
@@ -139,6 +168,20 @@ await adminClient.packageTypes.create({
|
|
|
139
168
|
})
|
|
140
169
|
```
|
|
141
170
|
|
|
171
|
+
```bash cURL
|
|
172
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/package_types' \
|
|
173
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
174
|
+
-H 'Content-Type: application/json' \
|
|
175
|
+
-d '{
|
|
176
|
+
"name": "Standard mailer",
|
|
177
|
+
"kind": "box",
|
|
178
|
+
"length": 30, "width": 20, "height": 15, "dimensions_unit": "cm",
|
|
179
|
+
"weight": 0.4, "weight_unit": "kg",
|
|
180
|
+
"default": true
|
|
181
|
+
}'
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
|
|
142
185
|
One row is marked `default`, and that is the box quotes are built on. Marking
|
|
143
186
|
another one default demotes the first, so there is never more than one.
|
|
144
187
|
|
|
@@ -162,6 +205,7 @@ falling back to the marketplace's when a seller has not recorded one. Sellers
|
|
|
162
205
|
manage theirs through the Seller API, and can also pack products into the
|
|
163
206
|
marketplace's cartons — those they read but cannot change.
|
|
164
207
|
|
|
208
|
+
|
|
165
209
|
```typescript Seller SDK
|
|
166
210
|
// Their own packaging — what the seller panel's settings page lists.
|
|
167
211
|
const { data: mine } = await sellerClient.packageTypes.list({ owner: 'mine' })
|
|
@@ -176,6 +220,19 @@ available.forEach((row) => {
|
|
|
176
220
|
})
|
|
177
221
|
```
|
|
178
222
|
|
|
223
|
+
```bash cURL
|
|
224
|
+
# The seller's own packaging
|
|
225
|
+
curl 'https://marketplace.example.com/api/v3/seller/package_types?owner=mine' \
|
|
226
|
+
-H 'Authorization: Bearer $SELLER_JWT' \
|
|
227
|
+
-H 'X-Spree-Seller-Id: sel_xxx'
|
|
228
|
+
|
|
229
|
+
# Theirs plus the marketplace's shared rows
|
|
230
|
+
curl 'https://marketplace.example.com/api/v3/seller/package_types' \
|
|
231
|
+
-H 'Authorization: Bearer $SELLER_JWT' \
|
|
232
|
+
-H 'X-Spree-Seller-Id: sel_xxx'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
|
|
179
236
|
A seller's settings page asks for `owner: 'mine'`. A list mixing both owners
|
|
180
237
|
reads as "packaging is configured" while the seller has recorded nothing of
|
|
181
238
|
their own, which makes the shipping-box requirement look broken rather than
|