@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,253 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Commissions
|
|
3
|
+
description: What a marketplace charges its sellers — rates and the rules that target them, how a fee is calculated, and the settlement record left behind.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A marketplace earns by taking a cut of what its sellers sell. Commissions are how you describe that cut and how Spree records what was actually charged.
|
|
9
|
+
|
|
10
|
+
There are two halves, and — as with [promotions and discounts](promotions.md) — keeping them apart is what makes the whole thing work:
|
|
11
|
+
|
|
12
|
+
| | |
|
|
13
|
+
|---|---|
|
|
14
|
+
| **Commission rate** | Configuration. What you charge, and when. Editable. |
|
|
15
|
+
| **Commission line** | The record of one charge on one sale. Frozen. |
|
|
16
|
+
|
|
17
|
+
Editing a rate changes what the *next* sale is charged and never what a past one was.
|
|
18
|
+
|
|
19
|
+
```mermaid
|
|
20
|
+
flowchart LR
|
|
21
|
+
Sale["A seller's item sells"] --> Resolve["Find the first<br/>matching rate"]
|
|
22
|
+
Resolve --> Calc["Calculate the fee<br/>and its tax"]
|
|
23
|
+
Calc --> Line["Commission line<br/><i>frozen</i>"]
|
|
24
|
+
|
|
25
|
+
style Line fill:#e8f5e9,stroke:#2e7d32
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **NOTE:** **A commission is not a [fee](fees.md).** A fee is charged to the shopper and rolls into the order total. A commission is a settlement between the marketplace and the seller — the customer never sees it, and it never touches the order total.
|
|
29
|
+
|
|
30
|
+
## Rates
|
|
31
|
+
|
|
32
|
+
A rate says what to charge. It's either a percentage of the sale or a flat amount.
|
|
33
|
+
|
|
34
|
+
```typescript Admin SDK
|
|
35
|
+
await adminClient.commissionRates.create({
|
|
36
|
+
name: 'Electronics',
|
|
37
|
+
code: 'electronics',
|
|
38
|
+
kind: 'percentage',
|
|
39
|
+
value: '12.5',
|
|
40
|
+
enabled: true,
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
| Attribute | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `name` | What operators call it |
|
|
47
|
+
| `code` | An optional short handle, unique per store |
|
|
48
|
+
| `kind` | `percentage` or `fixed` |
|
|
49
|
+
| `value` | The percentage, for a percentage rate |
|
|
50
|
+
| `enabled` | Whether it's in play |
|
|
51
|
+
| `position` | Where it sits in the list — see below |
|
|
52
|
+
| `tax_inclusive` | Whether the fee is charged on the gross or net amount |
|
|
53
|
+
| `include_shipping` | Whether delivery is commissioned too |
|
|
54
|
+
| `commission_tax_rate` | An explicit tax rate for the fee, overriding the default |
|
|
55
|
+
|
|
56
|
+
## The list is the precedence
|
|
57
|
+
|
|
58
|
+
This is the most important thing to understand about commission rates, and it's deliberately different from how you might expect.
|
|
59
|
+
|
|
60
|
+
When a sale happens, Spree walks the store's enabled rates **in list order** and takes **the first one whose rules match**. There's no scoring, and no built-in hierarchy where a product rule beats a category rule.
|
|
61
|
+
|
|
62
|
+
> **INFO:** What an operator sees in the table is exactly what resolution does. A marketplace that wants a different answer drags a row up or down, rather than reasoning about which rule type counts as "more specific".
|
|
63
|
+
|
|
64
|
+
Two consequences worth planning around:
|
|
65
|
+
|
|
66
|
+
**A rate with no rules matches everything.** That's how you express a default — and it belongs at the **bottom** of the list, because anything below it is unreachable.
|
|
67
|
+
|
|
68
|
+
**New rates are created at the top.** A rate is created to say something more specific than what's already there, and appending it below the catch-all would leave it dead on arrival.
|
|
69
|
+
|
|
70
|
+
```mermaid
|
|
71
|
+
flowchart TB
|
|
72
|
+
R1["1. Electronics — 12.5%<br/><i>category rule</i>"] --> R2
|
|
73
|
+
R2["2. Trusted sellers — 8%<br/><i>seller rule</i>"] --> R3
|
|
74
|
+
R3["3. Default — 15%<br/><i>no rules</i>"]
|
|
75
|
+
|
|
76
|
+
style R3 fill:#fff3e0,stroke:#e65100
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
If nothing matches, **no commission is charged**. That's a real answer, not a fallback — a marketplace with no rate covering a sale charges nothing rather than inventing a default nobody configured.
|
|
80
|
+
|
|
81
|
+
## Rules
|
|
82
|
+
|
|
83
|
+
Rules narrow when a rate applies. A rate can hold several, and **all of them must match** — while the IDs listed *within* one rule are alternatives.
|
|
84
|
+
|
|
85
|
+
So "electronics **and** these three sellers" is two rules on one rate.
|
|
86
|
+
|
|
87
|
+
```typescript Admin SDK
|
|
88
|
+
// Every rule kind, with the schema describing its settings — so an editor
|
|
89
|
+
// is built from what this marketplace actually has, not a hardcoded list
|
|
90
|
+
const { data: types } = await adminClient.commissionRates.ruleTypes()
|
|
91
|
+
|
|
92
|
+
await adminClient.commissionRates.update('crate_xxx', {
|
|
93
|
+
rules: [
|
|
94
|
+
{ type: 'category_rule', preferences: { category_ids: ['ctg_xxx'] } },
|
|
95
|
+
{ type: 'seller_rule', preferences: { seller_ids: ['sel_xxx', 'sel_yyy'] } },
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Rules are replaced wholesale on update — send the full set you want.
|
|
101
|
+
|
|
102
|
+
### The four rule types
|
|
103
|
+
|
|
104
|
+
<details>
|
|
105
|
+
<summary>Product rule — these specific products</summary>
|
|
106
|
+
|
|
107
|
+
Charges the rate only for the products named.
|
|
108
|
+
|
|
109
|
+
Products are stored as real references rather than a list of IDs in a field, so a marketplace naming a thousand products stays workable.
|
|
110
|
+
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
<details>
|
|
114
|
+
<summary>Category rule — anything filed here</summary>
|
|
115
|
+
|
|
116
|
+
Charges the rate for products in the named categories.
|
|
117
|
+
|
|
118
|
+
**A category matches its descendants too.** A rate on "Electronics" governs a camera under Electronics → Cameras, so you don't restate the rule every time someone adds a subcategory.
|
|
119
|
+
|
|
120
|
+
</details>
|
|
121
|
+
|
|
122
|
+
<details>
|
|
123
|
+
<summary>Seller rule — these sellers</summary>
|
|
124
|
+
|
|
125
|
+
Charges the rate only when the seller is one of those named. This is how a negotiated rate for a large vendor is expressed.
|
|
126
|
+
|
|
127
|
+
A rule naming nobody narrows nothing — and rather than silently charging every seller, it's treated as not matching.
|
|
128
|
+
|
|
129
|
+
</details>
|
|
130
|
+
|
|
131
|
+
<details>
|
|
132
|
+
<summary>Item total rule — sales in a value band</summary>
|
|
133
|
+
|
|
134
|
+
Charges the rate only on sales within a value range. "15% under 50, 10% above" is two rates, each holding one of these.
|
|
135
|
+
|
|
136
|
+
Bounds are **inclusive at the bottom and exclusive at the top**, so two bands can meet at a number without overlapping or leaving a gap.
|
|
137
|
+
|
|
138
|
+
The band is weighed against exactly the same figure the fee is charged on, so a band can never admit a sale the fee then treats as worth something different.
|
|
139
|
+
|
|
140
|
+
</details>
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
One rule of each type per rate. Rules can only name products and sellers belonging to the same store — pointing one at another marketplace's catalog is refused rather than quietly ignored.
|
|
144
|
+
|
|
145
|
+
## What the fee is charged on
|
|
146
|
+
|
|
147
|
+
Two settings on the rate decide the base, and both matter more than they first appear.
|
|
148
|
+
|
|
149
|
+
### Gross or net
|
|
150
|
+
|
|
151
|
+
`tax_inclusive` decides whether the fee is charged on the amount including the customer's tax, or excluding it.
|
|
152
|
+
|
|
153
|
+
**The default is net**, and that default is deliberate: the customer's VAT isn't the seller's revenue, and charging commission on it would tax the same money twice under two different regimes.
|
|
154
|
+
|
|
155
|
+
Discounts come off either way. Commission is charged on what the customer actually paid, so a promotion is the seller's concession.
|
|
156
|
+
|
|
157
|
+
### Delivery
|
|
158
|
+
|
|
159
|
+
`include_shipping` adds a commission on the delivery charge as well as the goods.
|
|
160
|
+
|
|
161
|
+
> **WARNING:** A **flat** rate cannot commission delivery. A flat fee is charged per sale, so charging the same amount again on the parcel would double it. A marketplace wanting a flat charge on delivery states it as its own rate.
|
|
162
|
+
|
|
163
|
+
## Amounts and currencies
|
|
164
|
+
|
|
165
|
+
Money is stated per currency, never converted:
|
|
166
|
+
|
|
167
|
+
```typescript Admin SDK
|
|
168
|
+
// A percentage, with a floor and a cap in each currency
|
|
169
|
+
await adminClient.commissionRates.create({
|
|
170
|
+
name: 'Standard',
|
|
171
|
+
kind: 'percentage',
|
|
172
|
+
value: 10,
|
|
173
|
+
bounds: {
|
|
174
|
+
USD: { min_amount: 1, max_amount: 50 },
|
|
175
|
+
EUR: { min_amount: 1, max_amount: 45 },
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// A flat fee, stated per currency
|
|
180
|
+
await adminClient.commissionRates.create({
|
|
181
|
+
name: 'Listing fee',
|
|
182
|
+
kind: 'fixed',
|
|
183
|
+
value: 0,
|
|
184
|
+
amounts: { USD: '5.00', GBP: '4.00' },
|
|
185
|
+
})
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- **`amounts`** — what a flat fee charges, per currency
|
|
189
|
+
- **`bounds`** — the floor and cap a percentage charges within, per currency
|
|
190
|
+
|
|
191
|
+
Both replace the whole set on write, and writing `bounds` never disturbs `amounts`.
|
|
192
|
+
|
|
193
|
+
Percentages and flat fees behave differently across currencies, for a good reason:
|
|
194
|
+
|
|
195
|
+
| | Behaviour in a currency with no stated amount |
|
|
196
|
+
|---|---|
|
|
197
|
+
| **Flat rate** | Doesn't apply — resolution falls through to the next rate. Converting one currency's figure into another would invent a fee nobody set. |
|
|
198
|
+
| **Percentage** | Still applies, uncapped. A ratio travels; its floor and cap don't. A marketplace that capped its dollar fees hasn't thereby said it wants no commission on euro sales. |
|
|
199
|
+
|
|
200
|
+
A flat fee is charged **per unit**, not per line. Otherwise buying three cameras together would earn a third of buying them separately — letting the shopper decide the marketplace's revenue.
|
|
201
|
+
|
|
202
|
+
## Tax on commission
|
|
203
|
+
|
|
204
|
+
The commission is the marketplace's own service to the seller. That's a separate taxable supply from the seller's sale to the customer, with its own place of supply — which is why the two taxes are worked out independently and never mix.
|
|
205
|
+
|
|
206
|
+
The tax rate is resolved in this order:
|
|
207
|
+
|
|
208
|
+
1. An explicit `commission_tax_rate` on the rate
|
|
209
|
+
2. The store's tax engine, using the **seller's** address
|
|
210
|
+
3. The store's default commission tax rate
|
|
211
|
+
|
|
212
|
+
> **WARNING:** Commission tax follows the **seller's** jurisdiction, not the shopper's. A German marketplace charging a French seller is a cross-border B2B supply, and the shopper's location has nothing to do with it.
|
|
213
|
+
|
|
214
|
+
Each line carries a `taxability_reason` — `standard_rated`, `zero_rated`, `reverse_charge` — using the same vocabulary as [tax lines](taxes.md) on goods. An invoice explaining why a marketplace fee was reverse-charged should use the same words as one explaining it for goods.
|
|
215
|
+
|
|
216
|
+
## Commission lines
|
|
217
|
+
|
|
218
|
+
When an order is placed, the resolved rate is applied and the result frozen as a commission line — one per item, plus one per delivery where the rate includes it.
|
|
219
|
+
|
|
220
|
+
| Attribute | Description |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `amount` | The fee |
|
|
223
|
+
| `tax_amount` | Tax on the fee |
|
|
224
|
+
| `total` | The two added together |
|
|
225
|
+
| `rate` / `kind` | A snapshot of what was applied |
|
|
226
|
+
| `tax_rate` / `taxability_reason` | How it was taxed, and why |
|
|
227
|
+
| `country_code` / `state_code` | The seller's jurisdiction |
|
|
228
|
+
| `currency` | The sale's currency |
|
|
229
|
+
| `line_item_id` / `fulfillment_id` | What was commissioned — exactly one |
|
|
230
|
+
| `commission_rate_id` | The rate, if it still exists |
|
|
231
|
+
|
|
232
|
+
```typescript Admin SDK
|
|
233
|
+
const { data: lines } = await adminClient.commissionLines.list({
|
|
234
|
+
filter: { seller_id_eq: 'sel_xxx' },
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**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
|
+
|
|
240
|
+
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.
|
|
241
|
+
|
|
242
|
+
## What this doesn't do
|
|
243
|
+
|
|
244
|
+
Commission lines say what the marketplace charged. They don't move money.
|
|
245
|
+
|
|
246
|
+
> **WARNING:** Open source has **no payout ledger**. Paying sellers means connecting a provider such as Stripe Connect, or building against the commission lines and [payment splits](sellers.md#one-checkout-several-sellers). Scheduled payout runs, refund clawbacks and reconciliation are Enterprise features.
|
|
247
|
+
|
|
248
|
+
## Related
|
|
249
|
+
|
|
250
|
+
- [Sellers](sellers.md) — marketplaces, order splitting and payment splits
|
|
251
|
+
- [Taxes](taxes.md) — the tax vocabulary commission lines share
|
|
252
|
+
- [Fees](fees.md) — buyer-facing charges, which commissions are not
|
|
253
|
+
- [Promotions](promotions.md) — the same rules-and-record split, on the buyer side
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Companies
|
|
3
|
+
description: B2B buyers as organizations — a company tree with members, shared addresses, and purchases made on the company's behalf.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
Selling to businesses breaks an assumption most storefronts make: that a customer is a person. A business buyer is a person acting *for* an organization — one that has other buyers, several delivery sites, a VAT number, and a finance team who wants to see everything anyone ordered.
|
|
9
|
+
|
|
10
|
+
A company in Spree models that. And because real organizations aren't flat, a company can be a **tree**: a parent with subsidiaries, divisions, regional units, each with its own people and addresses.
|
|
11
|
+
|
|
12
|
+
```mermaid
|
|
13
|
+
flowchart TB
|
|
14
|
+
Root["Acme Group<br/><i>company</i>"]
|
|
15
|
+
Root --> EU["Acme Europe<br/><i>company</i>"]
|
|
16
|
+
Root --> US["Acme US<br/><i>company</i>"]
|
|
17
|
+
EU --> Sales["Sales<br/><i>division</i>"]
|
|
18
|
+
EU --> Ops["Operations<br/><i>division</i>"]
|
|
19
|
+
|
|
20
|
+
style Root fill:#e3f2fd,stroke:#0077ff
|
|
21
|
+
style EU fill:#e3f2fd,stroke:#0077ff
|
|
22
|
+
style US fill:#e3f2fd,stroke:#0077ff
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A node is one of two kinds, and the difference is about tax, not hierarchy:
|
|
26
|
+
|
|
27
|
+
| Kind | What it is |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `company` | A legal entity — it can hold a tax registration |
|
|
30
|
+
| `division` | An organizational unit inside one — it cannot |
|
|
31
|
+
|
|
32
|
+
Trees are capped at five levels, and the root must always be a `company`.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
erDiagram
|
|
36
|
+
Company ||--o{ Company : "parent of"
|
|
37
|
+
Company ||--o{ CompanyMembership : "members"
|
|
38
|
+
Company ||--o{ Address : "address book"
|
|
39
|
+
Company ||--o{ CompanyInvitation : "pending invites"
|
|
40
|
+
Company ||--o{ TaxIdentifier : "legal entities only"
|
|
41
|
+
Company ||--o{ Order : "purchases"
|
|
42
|
+
|
|
43
|
+
Company {
|
|
44
|
+
string name
|
|
45
|
+
string kind
|
|
46
|
+
string parent_id
|
|
47
|
+
}
|
|
48
|
+
CompanyMembership {
|
|
49
|
+
string customer_id
|
|
50
|
+
string email
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Membership covers a subtree
|
|
55
|
+
|
|
56
|
+
Someone belongs to a company through a membership, and **that standing reaches everything below the node** — not just the node itself.
|
|
57
|
+
|
|
58
|
+
So a buyer attached to "Acme Europe" can act for Sales and Operations beneath it, without anyone creating three memberships. Give someone standing at the root and they cover the whole group.
|
|
59
|
+
|
|
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
|
+
|
|
62
|
+
```typescript Store SDK
|
|
63
|
+
// Which companies can this customer act for?
|
|
64
|
+
const { data: memberships } = await client.account.companies()
|
|
65
|
+
|
|
66
|
+
memberships.forEach((m) => {
|
|
67
|
+
m.company.name // "Acme Europe"
|
|
68
|
+
m.company.ancestors // [{ name: "Acme Group", … }] — the path above it
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> **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
|
+
>
|
|
74
|
+
> To restrict what individual people may do, see [company governance](#company-governance) below.
|
|
75
|
+
|
|
76
|
+
## Adding people
|
|
77
|
+
|
|
78
|
+
Both the dashboard and the storefront add members the same way — by email:
|
|
79
|
+
|
|
80
|
+
```typescript Store SDK
|
|
81
|
+
const result = await client.companies.members.create('comp_xxx', {
|
|
82
|
+
customer_email: 'buyer@acme.com',
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
What comes back depends on whether that email is already a customer:
|
|
87
|
+
|
|
88
|
+
- **An existing customer** becomes a member straight away.
|
|
89
|
+
- **An unknown email** produces an invitation, valid for 30 days, and an email with a link.
|
|
90
|
+
|
|
91
|
+
You can tell which by the ID prefix — `cmem_` for a membership, `cinv_` for an invitation.
|
|
92
|
+
|
|
93
|
+
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
|
+
|
|
95
|
+
```typescript Store SDK
|
|
96
|
+
// The token comes from the invitation email — no sign-in needed to read it
|
|
97
|
+
const invitation = await client.companyInvitations.lookup(token)
|
|
98
|
+
invitation.company_name
|
|
99
|
+
|
|
100
|
+
await client.companyInvitations.accept(token, {
|
|
101
|
+
first_name: 'Dana',
|
|
102
|
+
last_name: 'Reid',
|
|
103
|
+
password: 'a-strong-password',
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
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
|
+
|
|
109
|
+
## Buying for a company
|
|
110
|
+
|
|
111
|
+
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
|
+
|
|
113
|
+
```typescript Store SDK
|
|
114
|
+
await client.carts.update(cartId, { company_id: 'comp_xxx' })
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
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
|
+
|
|
119
|
+
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
|
+
|
|
121
|
+
```typescript Store SDK
|
|
122
|
+
// Everything anyone in the subtree has bought
|
|
123
|
+
const { data: orders } = await client.companies.orders.list('comp_xxx')
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
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
|
+
|
|
128
|
+
## The shared address book
|
|
129
|
+
|
|
130
|
+
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
|
+
|
|
132
|
+
```typescript Store SDK
|
|
133
|
+
await client.companies.addresses.create('comp_xxx', {
|
|
134
|
+
label: 'Northern Warehouse',
|
|
135
|
+
first_name: 'Goods',
|
|
136
|
+
last_name: 'Inwards',
|
|
137
|
+
address1: '14 Dock Road',
|
|
138
|
+
city: 'Rotterdam',
|
|
139
|
+
country_code: 'NL',
|
|
140
|
+
postal_code: '3011',
|
|
141
|
+
default_shipping: true,
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
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
|
+
|
|
147
|
+
## Tax
|
|
148
|
+
|
|
149
|
+
Tax registrations and exemption certificates live **only on legal entities**, and a purchase resolves tax through the node's nearest `company` ancestor.
|
|
150
|
+
|
|
151
|
+
```typescript Admin SDK
|
|
152
|
+
await adminClient.companies.taxIdentifiers.create('comp_xxx', {
|
|
153
|
+
kind: 'eu_vat',
|
|
154
|
+
value: 'NL123456789B01',
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Validation runs against the official registry, in the background
|
|
158
|
+
await adminClient.companies.taxIdentifiers.validate('comp_xxx', 'txid_xxx')
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> **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
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
See [Taxes](taxes.md) for how these reach the tax calculation.
|
|
166
|
+
|
|
167
|
+
## Company governance
|
|
168
|
+
|
|
169
|
+
Open source deliberately trusts every member equally. That's the right default for a company of five people, and the wrong one for a company of five hundred — where a junior buyer should not be able to place a £40,000 order, remove their manager, or see what another division spent.
|
|
170
|
+
|
|
171
|
+
Company governance adds that control. It works through the same endpoints and the same data, so nothing about your storefront integration changes when it's switched on — the calls you already make simply start enforcing.
|
|
172
|
+
|
|
173
|
+
- [Requires a Spree Enterprise licence](https://spreecommerce.org/enterprise/) — Company roles, order approvals, spending limits and governance audit history are part of Spree Enterprise. See what's included, or talk to the team about your use case.
|
|
174
|
+
|
|
175
|
+
### Company roles
|
|
176
|
+
|
|
177
|
+
Where open source has a cosmetic label, Enterprise has real roles built from a fixed set of capabilities:
|
|
178
|
+
|
|
179
|
+
| Capability | Allows |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `place_orders` | Completing a purchase |
|
|
182
|
+
| `approve_orders` | Approving what others submit |
|
|
183
|
+
| `view_node_purchases` | Seeing this node's orders |
|
|
184
|
+
| `view_company_purchases` | Seeing the whole subtree's orders |
|
|
185
|
+
| `manage_company_members` | Adding and removing people |
|
|
186
|
+
| `manage_company_addresses` | Editing the address book |
|
|
187
|
+
|
|
188
|
+
Roles are **data, edited by the company's own administrator** rather than by your staff — the buying organization manages its own structure, which is the point of self-service. Guards prevent someone granting themselves more than they hold.
|
|
189
|
+
|
|
190
|
+
These are entirely separate from [staff roles](staff-roles.md). A company role never reaches anything belonging to the store.
|
|
191
|
+
|
|
192
|
+
### Order approvals
|
|
193
|
+
|
|
194
|
+
A member without `place_orders` submits their basket instead of completing it. Someone with `approve_orders` reviews and releases it.
|
|
195
|
+
|
|
196
|
+
```mermaid
|
|
197
|
+
flowchart LR
|
|
198
|
+
Buyer["Buyer submits"] --> Pending["Awaiting approval"]
|
|
199
|
+
Pending --> Approved["Approved → order placed"]
|
|
200
|
+
Pending --> Rejected["Rejected, with a reason"]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Checkout returns a well-known `approval_required` response rather than a generic failure, so a storefront can show the right message and the right next step. The approval history is append-only — who asked, who decided, when.
|
|
204
|
+
|
|
205
|
+
### Spending limits
|
|
206
|
+
|
|
207
|
+
Limits are set per member or per node, with a reset period — £5,000 a month for a buyer, £50,000 for a division. They're enforced at completion, alongside approvals, so an over-limit basket is caught before payment rather than after.
|
|
208
|
+
|
|
209
|
+
### Restricted self-service
|
|
210
|
+
|
|
211
|
+
The same storefront endpoints — the company directory, address book, member management, order history — become capability-gated. A member without `view_company_purchases` sees their own node's orders and not the group's; a member without `manage_company_members` can read the team but not change it.
|
|
212
|
+
|
|
213
|
+
> **INFO:** **Nothing about the data model changes.** Governance enforces through a policy layer and the checkout validation hooks, over the same companies, memberships and endpoints described on this page. You can build a B2B storefront against open source today and have it work unchanged when governance is enabled.
|
|
214
|
+
|
|
215
|
+
Payment terms and net invoicing, quotes, and a packaged buyer portal are on the Enterprise roadmap — [talk to the team](https://spreecommerce.org/enterprise/) if those matter to your timeline.
|
|
216
|
+
|
|
217
|
+
## Managing companies as staff
|
|
218
|
+
|
|
219
|
+
```typescript Admin SDK
|
|
220
|
+
// Roots only
|
|
221
|
+
const { data: roots } = await adminClient.companies.list({ parent_id_null: 1 })
|
|
222
|
+
|
|
223
|
+
// Children of one node
|
|
224
|
+
const { data: children } = await adminClient.companies.list({ parent_id_eq: 'comp_xxx' })
|
|
225
|
+
|
|
226
|
+
const division = await adminClient.companies.create({
|
|
227
|
+
name: 'Sales',
|
|
228
|
+
kind: 'division',
|
|
229
|
+
parent_id: 'comp_xxx',
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
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
|
+
|
|
235
|
+
## Related
|
|
236
|
+
|
|
237
|
+
- [Catalogs](catalogs.md) — giving a company its own range and prices
|
|
238
|
+
- [Customers](customers.md) — the accounts memberships are built on
|
|
239
|
+
- [Taxes](taxes.md) — registrations and exemptions
|
|
240
|
+
- [Orders](orders.md) — company purchases
|
|
@@ -3,8 +3,6 @@ title: Customers
|
|
|
3
3
|
description: How Spree models customer accounts — registration, authentication, addresses, order history, store credits, and guest checkout behavior.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
import { Since } from '/snippets/since.mdx';
|
|
7
|
-
|
|
8
6
|
## Overview
|
|
9
7
|
|
|
10
8
|
Customers interact with your store through the Store API. They can register, log in, manage their profile, and view order history.
|
|
@@ -193,7 +191,6 @@ On success, the user is automatically logged in and a JWT token is returned. The
|
|
|
193
191
|
|
|
194
192
|
## Newsletter Subscriptions
|
|
195
193
|
|
|
196
|
-
|
|
197
194
|
Headless storefronts often need to collect newsletter signups before account creation (footer forms, popup overlays). The Store API exposes a double opt-in subscription flow that mirrors the password reset webhook pattern.
|
|
198
195
|
|
|
199
196
|
### Step 1: Subscribe
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Discounts
|
|
3
|
+
description: The record of money taken off one order — where a discount comes from, and why it is spread across the items it applied to.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A discount is one line on one order saying money came off, and why.
|
|
9
|
+
|
|
10
|
+
> **NOTE:** **A discount is not a promotion.** A [promotion](promotions.md) is a *rule* — "20% off shoes in July" — that exists on its own, before anybody buys anything. A discount is the *result* on a particular order.
|
|
11
|
+
>
|
|
12
|
+
> Think of the promotion as the sign in the shop window and the discount as the line on the receipt. One promotion produces thousands of discounts over its life.
|
|
13
|
+
|
|
14
|
+
That separation is what lets an order stay truthful. The promotion can be edited, paused or deleted next month; the discount row on last month's order still says exactly what that customer was given.
|
|
15
|
+
|
|
16
|
+
It also means **not every discount has a promotion behind it.** When a support agent takes $10 off as a goodwill gesture, that's a discount with no campaign attached — which is what the `kind` field distinguishes.
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
erDiagram
|
|
20
|
+
Promotion ||--o{ Discount : "produces many, over time"
|
|
21
|
+
Order ||--o{ Discount : "has many"
|
|
22
|
+
LineItem ||--o{ Discount : "reduced by"
|
|
23
|
+
Fulfillment ||--o{ Discount : "reduced by"
|
|
24
|
+
|
|
25
|
+
Promotion {
|
|
26
|
+
string name
|
|
27
|
+
string code
|
|
28
|
+
datetime expires_at
|
|
29
|
+
}
|
|
30
|
+
Discount {
|
|
31
|
+
string label
|
|
32
|
+
string kind
|
|
33
|
+
string code
|
|
34
|
+
string value
|
|
35
|
+
string amount
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| | [Promotion](promotions.md) | Discount |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| What it is | A campaign rule | A row on one order |
|
|
42
|
+
| Exists | Before any order | Only once money comes off |
|
|
43
|
+
| How many | One | One per order it applies to |
|
|
44
|
+
| Can change later | Yes — edit or end it | No — it's a financial record |
|
|
45
|
+
| Set up by | A merchant, in advance | Created automatically, or by staff |
|
|
46
|
+
|
|
47
|
+
## Discount attributes
|
|
48
|
+
|
|
49
|
+
| Attribute | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `label` | What the customer sees — `Summer Sale` |
|
|
52
|
+
| `kind` | `promotion` or `manual` |
|
|
53
|
+
| `code` | The coupon code used, if there was one |
|
|
54
|
+
| `value` / `value_type` | The rule behind it — `"10"` and `percent` |
|
|
55
|
+
| `amount` / `display_amount` | What actually came off, as a negative amount |
|
|
56
|
+
| `promotion_id` | The promotion responsible, for promotion discounts |
|
|
57
|
+
| `line_item_id` / `fulfillment_id` | What it reduced |
|
|
58
|
+
|
|
59
|
+
Note the difference between `value` and `amount`. `value` is the rule — "10 percent". `amount` is the money — "-$12.00". You need the rule to explain the discount and the amount to add up the order.
|
|
60
|
+
|
|
61
|
+
## Reading discounts on an order
|
|
62
|
+
|
|
63
|
+
Whatever produced them, the rows read the same way:
|
|
64
|
+
|
|
65
|
+
```typescript Store SDK
|
|
66
|
+
const cart = await client.carts.get(cartId)
|
|
67
|
+
|
|
68
|
+
cart.display_discount_total // "-$12.00"
|
|
69
|
+
|
|
70
|
+
cart.discounts.forEach((discount) => {
|
|
71
|
+
discount.label // "Summer Sale"
|
|
72
|
+
discount.kind // "promotion" or "manual"
|
|
73
|
+
discount.code // "SUMMER20", when a code was used
|
|
74
|
+
discount.display_amount // "-$12.00"
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Applying a coupon code, and what decides whether it's accepted, belongs to [Promotions](promotions.md).
|
|
79
|
+
|
|
80
|
+
## Manual discounts
|
|
81
|
+
|
|
82
|
+
Sometimes there's no promotion — a price match, an apology for a late delivery. Staff can add a discount directly:
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
```typescript Admin SDK
|
|
86
|
+
// 10% off one line item
|
|
87
|
+
await adminClient.orders.discounts.create('or_xxx', {
|
|
88
|
+
label: 'Goodwill discount',
|
|
89
|
+
value: '10',
|
|
90
|
+
value_type: 'percent',
|
|
91
|
+
line_item_id: 'li_xxx',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// A flat amount across the order
|
|
95
|
+
await adminClient.orders.discounts.create('or_xxx', {
|
|
96
|
+
label: 'Price match',
|
|
97
|
+
value: '15.00',
|
|
98
|
+
value_type: 'flat',
|
|
99
|
+
})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash cURL
|
|
103
|
+
curl -X POST 'https://api.mystore.com/api/v3/admin/orders/or_xxx/discounts' \
|
|
104
|
+
-H 'X-Spree-API-Key: sk_xxx' \
|
|
105
|
+
-H 'Content-Type: application/json' \
|
|
106
|
+
-d '{ "label": "Goodwill discount", "value": "10", "value_type": "percent" }'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
> **WARNING:** Only **manual** discounts can be edited or deleted. Changing one that came from a promotion returns a `422`.
|
|
111
|
+
>
|
|
112
|
+
> That row belongs to the promotion that created it. If staff could edit it, an order would start disagreeing with the promotion it claims to have used — and the reporting behind "how did the summer sale do" stops adding up.
|
|
113
|
+
|
|
114
|
+
## How a discount is spread
|
|
115
|
+
|
|
116
|
+
An order-level discount isn't kept as one lump sum. It's divided across the items it applied to, in proportion to what they cost.
|
|
117
|
+
|
|
118
|
+
This looks like an implementation detail until someone returns one item out of three. Then the question is: how much of that $30 discount belonged to the returned shirt? If the discount were one lump, you'd have to guess. Because it was spread at the time, the answer is already recorded, and the refund is right without anyone doing arithmetic on the phone.
|
|
119
|
+
|
|
120
|
+
The same reasoning applies to tax — the tax on a partially returned order follows the same split.
|
|
121
|
+
|
|
122
|
+
## Discounts on placed orders
|
|
123
|
+
|
|
124
|
+
Once an order exists, its discounts stop being regenerated. Editing a placed order re-applies the rows it already has rather than re-running today's promotions.
|
|
125
|
+
|
|
126
|
+
The alternative would mean an order quietly changing because a sale ended overnight — which is indefensible to a customer holding a confirmation email.
|
|
127
|
+
|
|
128
|
+
## Related
|
|
129
|
+
|
|
130
|
+
- [Promotions](promotions.md) — the rules that create most discounts
|
|
131
|
+
- [Order totals](order-totals.md) — how discounts roll into the total
|
|
132
|
+
- [Gift cards & store credit](store-credits-gift-cards.md) — payment, not discount
|
|
133
|
+
- [Returns](returns-exchanges-claims.md) — how a discount is unwound on a return
|