@spree/docs 0.1.183 → 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.
@@ -0,0 +1,140 @@
1
+ ---
2
+ title: Catalogs
3
+ description: Showing different products and prices to different audiences — a wholesale range, a channel-specific selection, or one company's negotiated pricing.
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Not every shopper should see the same store. A wholesale buyer gets trade prices and a range retail never sees. A negotiated account has its own agreed pricing. A retail till carries a subset of what the website does.
9
+
10
+ A catalog answers both halves of that at once: **what an audience sees**, and **what they pay**.
11
+
12
+ ```mermaid
13
+ flowchart LR
14
+ Catalog --> Assortment["Assortment<br/><i>which products</i>"]
15
+ Catalog --> PriceList["Price list<br/><i>what they cost</i>"]
16
+ Catalog --> Audience["Assignments<br/><i>who gets it</i>"]
17
+ ```
18
+
19
+ ```mermaid
20
+ erDiagram
21
+ Catalog ||--o{ CatalogProduct : "assortment"
22
+ Catalog ||--o{ CatalogAssignment : "audiences"
23
+ Catalog }o--o| PriceList : "optional pricing"
24
+ CatalogAssignment }o--|| Channel : "one of"
25
+ CatalogAssignment }o--|| CustomerGroup : "one of"
26
+ CatalogAssignment }o--|| Market : "one of"
27
+ CatalogAssignment }o--|| Company : "one of"
28
+
29
+ Catalog {
30
+ string name
31
+ boolean active
32
+ integer position
33
+ }
34
+ ```
35
+
36
+ ## The two modes
37
+
38
+ The single most useful thing to understand about catalogs is that an **empty assortment means something different from a full one** — it's the switch between the two ways a catalog is used.
39
+
40
+
41
+ - **Pricing overlay** — **Assortment empty.** Nothing is hidden — the audience browses the normal store — but the attached price list applies.
42
+
43
+ This is how "this company sees everything, just at their negotiated prices" is expressed.
44
+ - **Restricted range** — **Assortment has products.** The audience sees *only* what's in it.
45
+
46
+ This is how a wholesale-only range, or a channel-specific selection, is expressed.
47
+
48
+
49
+ That's worth pausing on, because it's the one behaviour that surprises people: adding a product to an empty catalog doesn't add one item to what a customer sees — it switches the catalog into restricting mode, and they now see *only* that item.
50
+
51
+ ## Creating one
52
+
53
+ ```typescript Admin SDK
54
+ const catalog = await adminClient.catalogs.create({
55
+ name: 'Wholesale',
56
+ price_list_id: 'plist_xxx',
57
+ active: true,
58
+ })
59
+ ```
60
+
61
+ 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.
62
+
63
+ ### Filling the assortment
64
+
65
+ ```typescript Admin SDK
66
+ // Add products (a bare array of IDs)
67
+ await adminClient.catalogs.products.create(catalog.id, ['prod_xxx', 'prod_yyy'])
68
+
69
+ const { data: products } = await adminClient.catalogs.products.list(catalog.id)
70
+
71
+ await adminClient.catalogs.products.delete(catalog.id, 'prod_xxx')
72
+ ```
73
+
74
+ Products are positioned, so a catalog also controls the order they're presented in.
75
+
76
+ 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:
77
+
78
+ ```typescript Admin SDK
79
+ const { added_count } = await adminClient.catalogs.importProducts(catalog.id)
80
+ ```
81
+
82
+ ## Choosing the audience
83
+
84
+ A catalog is assigned to one of four things:
85
+
86
+ | Assign to | Meaning |
87
+ |---|---|
88
+ | **[Channel](channels.md)** | Everyone buying through this channel — an app, a retail till |
89
+ | **[Customer group](customers.md)** | A segment — wholesale accounts, staff, a loyalty tier |
90
+ | **[Market](markets.md)** | Everyone in a region |
91
+ | **[Company](companies.md)** | One B2B organization |
92
+
93
+ ```typescript Admin SDK
94
+ await adminClient.catalogs.assign(catalog.id, {
95
+ assignable_type: 'customer_group',
96
+ assignable_id: 'cgrp_xxx',
97
+ })
98
+ ```
99
+
100
+ > **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.
101
+
102
+ ## What a shopper ends up seeing
103
+
104
+ When several catalogs could apply, they combine rather than compete:
105
+
106
+ **Step 1: Find the catalogs that apply**
107
+
108
+ For a company buyer, that's the catalogs on their node **and its ancestors**. Otherwise their customer group's catalogs. Otherwise the channel's default catalog, if it has one.
109
+
110
+ **Step 2: Combine the assortments**
111
+
112
+ The shopper sees the union of what those catalogs contain — so a division's extra catalog adds to the group's range rather than replacing it.
113
+
114
+ **Step 3: Any empty assortment lifts the restriction**
115
+
116
+ If one applicable catalog is a pricing overlay, the restriction is off and the shopper sees the full range. An overlay is explicitly "don't hide anything", and that has to win — otherwise adding negotiated pricing would accidentally narrow someone's catalog.
117
+
118
+
119
+ Gated storefront access is checked before any of this — a shopper who has to sign in never reaches catalog resolution.
120
+
121
+ ## How pricing resolves
122
+
123
+ Prices are checked in order, and the first match wins:
124
+
125
+ 1. Price lists attached to the applicable catalogs, **nearest first** — the buyer's own company node before its parent's
126
+ 2. Ordinary price lists whose rules match
127
+ 3. The product's base price
128
+
129
+ Nearest-first is what lets a subsidiary hold a better-negotiated rate than the group's, without disturbing anyone else.
130
+
131
+ > **WARNING:** A price list attached to a catalog applies **because the catalog applies** — its own rules are not consulted, and it's excluded from ordinary rule matching.
132
+ >
133
+ > That exclusion is load-bearing: a price list with no rules would otherwise match everyone, and one company's negotiated pricing would leak to the entire storefront.
134
+
135
+ ## Related
136
+
137
+ - [Companies](companies.md) — B2B buyers and the subtree rule
138
+ - [Pricing](pricing.md) — price lists and rules
139
+ - [Products](products.md) — the catalog being narrowed
140
+ - [Channels](channels.md) — per-channel default catalogs
@@ -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