@spree/docs 0.1.180 → 0.1.182
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,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Companies & Catalogs
|
|
3
|
+
description: How Spree models B2B buyer organizations — a multi-level company tree with members and an address book — and the catalogs that decide what each audience sees and pays.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
A business customer in Spree is a **tree of company nodes**, not a flat account. One node can stand alone — a company with a few members and addresses — or grow into a group: subsidiaries, divisions, regional units, each with its own buyers and ship-to sites.
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
erDiagram
|
|
12
|
+
Company ||--o{ Company : "parent of"
|
|
13
|
+
Company ||--o{ CompanyMembership : "has many"
|
|
14
|
+
Company ||--o{ CompanyAddress : "has many"
|
|
15
|
+
Company ||--o{ CompanyInvitation : "has many"
|
|
16
|
+
Company ||--o{ TaxIdentifier : "legal entities only"
|
|
17
|
+
Company ||--o{ TaxExemptionCertificate : "legal entities only"
|
|
18
|
+
|
|
19
|
+
Company {
|
|
20
|
+
string name
|
|
21
|
+
string kind "company | division"
|
|
22
|
+
string external_id
|
|
23
|
+
}
|
|
24
|
+
CompanyMembership {
|
|
25
|
+
string role "cosmetic label"
|
|
26
|
+
}
|
|
27
|
+
CompanyAddress {
|
|
28
|
+
string label
|
|
29
|
+
boolean default_billing
|
|
30
|
+
boolean default_shipping
|
|
31
|
+
}
|
|
32
|
+
CompanyInvitation {
|
|
33
|
+
string email
|
|
34
|
+
string token
|
|
35
|
+
datetime expires_at
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Three ideas carry the whole model:
|
|
40
|
+
|
|
41
|
+
- **The tree is structure.** `Spree::Company` is self-referential (`parent_id`), store-scoped, and shallow — depth is capped at five levels. How many ship-to sites a node has never dictates how many nodes exist; addresses are just an address book.
|
|
42
|
+
- **Nodes are typed, and the type decides tax.** A `company` node is a legal entity; a `division` is an organizational unit. Tax registrations and exemption certificates exist only on legal entities, and a purchase's tax always resolves through the node's `legal_entity` — for a `company` that is the node itself, and for a `division` it is its nearest `company` ancestor, whose registrations then cover the division's purchases. The walk stops at that first `company` node whether or not it holds any registration, so a subsidiary with no VAT number of its own has none — it never borrows its parent's.
|
|
43
|
+
- **Membership is standing over a subtree.** A `CompanyMembership` joins a customer to a node, and that standing covers the node and everything below it. Any authorization question — "may this customer act for node N?" — is a membership check on N and its ancestors, never an equality check on one node.
|
|
44
|
+
|
|
45
|
+
## Members and invitations
|
|
46
|
+
|
|
47
|
+
People join by email, from either side — staff in the dashboard or an existing member on the storefront. An email that matches a store customer becomes a membership immediately; an unknown email becomes a `Spree::CompanyInvitation` — a plaintext-token record with a 30-day expiry, whose invite email links to the storefront acceptance page. Accepting either registers a new account (through the standard customer-creation flow, with the invited email) or binds the invitation to the signed-in customer, and lands as a membership either way.
|
|
48
|
+
|
|
49
|
+
Memberships stay always-active and always customer-backed: the pending state lives on the invitation, never as a status on the membership.
|
|
50
|
+
|
|
51
|
+
**In open source, every member can do everything within their standing** — buy, see the subtree's purchases, manage addresses and members. There are no company roles; the `role` string on a membership is a cosmetic label. Finer control (roles, approvals, spending limits) is an Enterprise layer that enforces through the storefront access policy class (`Spree::Dependencies.storefront_access_policy_class`) and the checkout `validate` hooks — injection points that are deliberate no-ops in OSS.
|
|
52
|
+
|
|
53
|
+
## Purchases
|
|
54
|
+
|
|
55
|
+
Carts and orders carry a `company_id` pointing at any node — buying *for* a division means pointing at it. A buyer with exactly one membership resolves to it automatically; a buyer with several names the node on their cart (the `company_id` param on cart update, validated against their standing). The node is frozen onto the order at completion, like every other order attribute, so a placed order's tax treatment stays explainable no matter how the tree changes later.
|
|
56
|
+
|
|
57
|
+
Exemption certificates and the company's tax registration always resolve through `company.legal_entity` — see [Taxes, Discounts & Fees](/docs/developer/core-concepts/taxes-discounts-fees) for how they reach the tax provider.
|
|
58
|
+
|
|
59
|
+
## Catalogs
|
|
60
|
+
|
|
61
|
+
A `Spree::Catalog` narrows what an audience sees and — through an optional price list — what they pay.
|
|
62
|
+
|
|
63
|
+
```mermaid
|
|
64
|
+
erDiagram
|
|
65
|
+
Catalog ||--o{ CatalogProduct : "assortment"
|
|
66
|
+
Catalog ||--o{ CatalogAssignment : "audiences"
|
|
67
|
+
Catalog }o--o| PriceList : "optional pricing"
|
|
68
|
+
CatalogAssignment }o--|| Company : "or"
|
|
69
|
+
CatalogAssignment }o--|| CustomerGroup : "or"
|
|
70
|
+
CatalogAssignment }o--|| Market : "or"
|
|
71
|
+
CatalogAssignment }o--|| Channel : "or"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **Assortment** — a positioned product join, and the switch between a catalog's two modes. A *curated* assortment restricts: the audience sees only what's in it. An *empty* assortment is a pricing-only overlay: the attached price list applies and nothing is hidden — which is how "everyone sees the public store, this company just gets special prices" is expressed. An explicit import action copies the price list's products into the assortment when a restrictive catalog should start from the priced range.
|
|
75
|
+
- **Audiences** — assignments to a channel, a customer group, a market, or a company node. A company assignment covers the node's **subtree**: assign the group-wide catalog once at the root, and a branch adds its own extra catalog without re-assigning the shared one.
|
|
76
|
+
- **Visibility** — a buyer resolving to a company node sees the union of the effective catalogs on the node and its ancestors; otherwise their customer group's catalogs apply; otherwise every channel listing (narrowed to the channel's default catalog when one is set). Any effective catalog with an empty assortment lifts the restriction — the union then includes the whole range. Gated storefront access runs first — a login-gated guest never reaches this resolution.
|
|
77
|
+
- **Pricing** — the effective catalogs' price lists are checked nearest node first (the purchase node's own assignments before its parent's), then the ordinary rule-matched price lists, then base prices. A price list attached to a catalog applies *because the catalog applies* — its own rules are not consulted, and it is excluded from generic rule matching so a rule-less list cannot leak to every shopper.
|
|
78
|
+
|
|
79
|
+
## Storefront self-service
|
|
80
|
+
|
|
81
|
+
The Store API ships a full company directory for members: their memberships with ancestor paths (`GET /store/account/companies`), node details and renaming, the address book, members and invitations, and the subtree's completed orders. Authorization on every endpoint is **standing plus the access policy** — never roles, never CanCanCan. The deliberate trade-off: within a company, OSS trusts every member; merchants who need restraint layer the Enterprise governance on the same injection points, with no schema changes.
|
|
@@ -64,7 +64,7 @@ erDiagram
|
|
|
64
64
|
| `name` | Product name | Yes |
|
|
65
65
|
| `description` | Full product description | Yes |
|
|
66
66
|
| `slug` | URL-friendly identifier (e.g., `spree-tote`) | Yes |
|
|
67
|
-
| `status` | `draft`, `active`, or `archived` | No |
|
|
67
|
+
| `status` | `draft`, `active`, or `archived`. A marketplace adds `proposed` and `rejected` — see Seller submissions below | No |
|
|
68
68
|
| `available_on` | Date the product becomes available for sale | No |
|
|
69
69
|
| `discontinue_on` | Date the product is no longer available | No |
|
|
70
70
|
| `meta_title` | Custom SEO title | Yes |
|
|
@@ -221,6 +221,144 @@ spree api delete /products/prod_xxx
|
|
|
221
221
|
|
|
222
222
|
> **TIP:** Operating on many products at once? The Admin API has bulk actions — `bulkStatusUpdate`, `bulkAddToCategories`, `bulkAddTags`, `bulkDestroy`, and more. See the [Admin API endpoint index](../../api-reference/admin-api/endpoints.md).
|
|
223
223
|
|
|
224
|
+
## Seller submissions
|
|
225
|
+
|
|
226
|
+
On a marketplace, a seller lists a product but does not publish one. They submit it, and the marketplace decides. This adds two statuses to the three above — both hidden from the storefront, since only `active` is visible.
|
|
227
|
+
|
|
228
|
+
> **INFO:** This applies to products that belong to a seller. A marketplace's own catalog is unaffected: an operator publishing their own product sets `status` directly and answers to nobody.
|
|
229
|
+
|
|
230
|
+
### The lifecycle
|
|
231
|
+
|
|
232
|
+
```mermaid
|
|
233
|
+
stateDiagram-v2
|
|
234
|
+
[*] --> draft
|
|
235
|
+
draft --> proposed : seller submits
|
|
236
|
+
proposed --> active : operator approves
|
|
237
|
+
proposed --> rejected : operator sends back
|
|
238
|
+
rejected --> proposed : seller revises and resubmits
|
|
239
|
+
proposed --> draft : seller withdraws
|
|
240
|
+
proposed --> archived : seller withdraws
|
|
241
|
+
active --> draft : seller takes it down
|
|
242
|
+
active --> archived : seller withdraws it
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Withdrawing a submission before anyone has ruled on it closes the open row as `withdrawn`, so a `pending` row always means the marketplace still owes an answer. A product that was already rejected keeps that decision at the head of its trail instead.
|
|
246
|
+
|
|
247
|
+
| Status | Meaning | Storefront |
|
|
248
|
+
|---|---|:---:|
|
|
249
|
+
| `draft` | The seller is still working on it | Hidden |
|
|
250
|
+
| `proposed` | Submitted, waiting on the marketplace | Hidden |
|
|
251
|
+
| `rejected` | Sent back with a reason, awaiting changes | Hidden |
|
|
252
|
+
| `active` | Approved and on sale | **Visible** |
|
|
253
|
+
| `archived` | Withdrawn | Hidden |
|
|
254
|
+
|
|
255
|
+
A seller can always take their own listing down — that is not a review decision. Putting one up is.
|
|
256
|
+
|
|
257
|
+
### The submission record
|
|
258
|
+
|
|
259
|
+
Each submission and each decision on it is a `Spree::ProductSubmission` row. The product's `status` stays the operational truth; these rows are how it got there.
|
|
260
|
+
|
|
261
|
+
```mermaid
|
|
262
|
+
erDiagram
|
|
263
|
+
Product ||--o{ ProductSubmission : "has many"
|
|
264
|
+
AdminUser ||--o{ ProductSubmission : "submitted"
|
|
265
|
+
AdminUser ||--o{ ProductSubmission : "reviewed"
|
|
266
|
+
|
|
267
|
+
ProductSubmission {
|
|
268
|
+
string status
|
|
269
|
+
bigint product_id
|
|
270
|
+
bigint submitted_by_id
|
|
271
|
+
bigint reviewed_by_id
|
|
272
|
+
datetime reviewed_at
|
|
273
|
+
text review_note
|
|
274
|
+
json metadata
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
| Column | Description |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `status` | `pending`, `approved`, `rejected`, or `withdrawn` |
|
|
281
|
+
| `submitted_by_id` | The seller's staff member who asked |
|
|
282
|
+
| `reviewed_by_id` | The marketplace's staff member who decided |
|
|
283
|
+
| `reviewed_at` | When the decision was made |
|
|
284
|
+
| `review_note` | Why it was sent back — this is what the seller reads |
|
|
285
|
+
|
|
286
|
+
Rows accumulate rather than overwrite, so a seller sent back three times leaves three rows. The latest row for a product is the live one; the ones before it are the trail.
|
|
287
|
+
|
|
288
|
+
> **WARNING:** Never store a rejection reason on the product itself. A seller can write their own product's `metadata`, so a note kept there is erased the next time they save.
|
|
289
|
+
|
|
290
|
+
An approval with no `reviewed_by_id` and `metadata.auto_approved` set means the store approves listings automatically — never a decision whose author was lost. Turn that on with the `auto_approve_seller_products` store preference.
|
|
291
|
+
|
|
292
|
+
### Submitting, as a seller
|
|
293
|
+
|
|
294
|
+
Status is not writable on the seller branch. A seller moves a product with an explicit action:
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
```typescript Seller SDK
|
|
298
|
+
import { createSellerClient } from '@spree/seller-sdk'
|
|
299
|
+
|
|
300
|
+
const client = createSellerClient({
|
|
301
|
+
baseUrl: 'https://marketplace.example.com',
|
|
302
|
+
sellerId: 'sel_xxx',
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await client.products.submit('prod_xxx') // draft or rejected → proposed
|
|
306
|
+
await client.products.draft('prod_xxx') // take it back down
|
|
307
|
+
await client.products.archive('prod_xxx') // withdraw it
|
|
308
|
+
|
|
309
|
+
// Why it was sent back
|
|
310
|
+
const product = await client.products.get('prod_xxx', 'submission')
|
|
311
|
+
product.submission?.review_note
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
```bash CLI
|
|
315
|
+
spree api patch /seller/products/prod_xxx/submit
|
|
316
|
+
spree api patch /seller/products/prod_xxx/draft
|
|
317
|
+
spree api patch /seller/products/prod_xxx/archive
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
### Deciding, as the marketplace
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
```typescript Admin SDK
|
|
325
|
+
await client.products.approve('prod_xxx')
|
|
326
|
+
await client.products.reject('prod_xxx', { reason: 'Please add a photo showing scale.' })
|
|
327
|
+
|
|
328
|
+
// The review queue
|
|
329
|
+
const pending = await client.products.list({ filter: { status_eq: 'proposed' } })
|
|
330
|
+
|
|
331
|
+
// Who decided, and when
|
|
332
|
+
const product = await client.products.get('prod_xxx', { expand: ['submission'] })
|
|
333
|
+
product.submission?.reviewed_by_name
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```bash CLI
|
|
337
|
+
spree api patch /products/prod_xxx/approve
|
|
338
|
+
spree api patch /products/prod_xxx/reject -d '{"reason": "Please add a photo showing scale."}'
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
> **NOTE:** The seller sees the note and when the decision was made, but never who made it.
|
|
343
|
+
|
|
344
|
+
### Leaving review is a decision
|
|
345
|
+
|
|
346
|
+
A product in `proposed` or `rejected` cannot have its status changed by an ordinary update — that would put it on sale with nobody's name against it. The refusal lives in the product update workflow, so every caller inherits it, and bulk status updates skip those products and report how many they left behind.
|
|
347
|
+
|
|
348
|
+
### Events
|
|
349
|
+
|
|
350
|
+
Each transition publishes an event you can subscribe to:
|
|
351
|
+
|
|
352
|
+
| Event | Published when |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `product.proposed` | A seller submits for review |
|
|
355
|
+
| `product.approved` | The marketplace accepts it |
|
|
356
|
+
| `product.rejected` | The marketplace sends it back |
|
|
357
|
+
| `product.drafted` | A seller takes a listing down |
|
|
358
|
+
| `product.archived` | A seller withdraws it |
|
|
359
|
+
|
|
360
|
+
The submission row itself also publishes `product_submission.created` and `product_submission.updated`, carrying the status, the note and the product. See [Events](events.md).
|
|
361
|
+
|
|
224
362
|
## Product Filters
|
|
225
363
|
|
|
226
364
|
Get available filter options for building a faceted search UI. Returns price ranges, option values, and categories with counts:
|