@base44/app-plugin-commerce 0.1.20 → 0.2.2

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.
Files changed (73) hide show
  1. package/README.md +25 -22
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  11. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  12. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  13. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  14. package/base44/shared/commerce/card-payment.stripe.ts +198 -0
  15. package/base44/shared/commerce/card-payment.ts +1 -1
  16. package/base44/shared/commerce/payments.ts +2 -2
  17. package/base44/shared/commerce/scan.ts +1 -1
  18. package/base44/shared/commerce/sequence.ts +2 -2
  19. package/package.json +1 -1
  20. package/scripts/install.js +24 -14
  21. package/skills/commerce/SKILL.md +117 -51
  22. package/skills/commerce/docs/api-admin.md +89 -28
  23. package/skills/commerce/docs/api-storefront.md +113 -126
  24. package/skills/commerce/docs/entities.md +137 -0
  25. package/skills/commerce/install/01-install.md +101 -0
  26. package/skills/commerce/install/02-storefront.md +444 -0
  27. package/skills/commerce/install/03-data.md +162 -0
  28. package/skills/commerce/references/admin-product-form.md +10 -0
  29. package/skills/commerce/references/catalog-rendering.md +110 -0
  30. package/skills/commerce/references/emails.md +49 -12
  31. package/skills/commerce/references/guest-access-security.md +18 -5
  32. package/skills/commerce/references/online-payments.md +49 -149
  33. package/skills/commerce/references/operations.md +52 -0
  34. package/skills/commerce/references/reviews.md +31 -16
  35. package/skills/commerce/references/shipping-and-tax.md +110 -0
  36. package/skills/commerce/references/store-admin-agent.md +21 -0
  37. package/skills/commerce/references/store-settings.md +49 -0
  38. package/src/commerce/admin/README.md +6 -3
  39. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  40. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  41. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  42. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  43. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  44. package/src/commerce/storefront/index.js +74 -24
  45. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  46. package/src/commerce/storefront/useAddressForm.js +96 -0
  47. package/src/commerce/storefront/useCartLine.js +221 -0
  48. package/src/commerce/storefront/useCheckout.jsx +18 -6
  49. package/src/commerce/storefront/useOrderReturn.js +36 -10
  50. package/src/commerce/storefront/useProduct.js +295 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +22 -7
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/shipping-promos.js +2 -2
  62. package/src/commerce/utils/specs.js +26 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +58 -3
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -496
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -1,10 +1,23 @@
1
1
  # Storefront API Reference
2
2
 
3
- Everything needed to build a customer-facing shopfront (or headless client) against the Base44 Commerce Template. No visitor UI ships with the template this is your integration surface. Storefront *logic* does ship on two levels: the framework-free helpers in `src/commerce/utils/` (API client, variant selection, free-shipping rules) and the React layer in `src/commerce/storefront/` (shared-cart provider, guided-checkout hook, headless shipping/payment pickers, order-received hook) in a React app, build cart/checkout on those hooks and use this reference for what lies beyond them.
3
+ The raw HTTP/SDK surface behind a customer-facing shopfront. **A React storefront should not call most of it directly** — `src/commerce/storefront/` ships the logic layer, and this file is the reference for what lies beyond it, for non-React and headless clients, and for the exact payloads and error codes.
4
+
5
+ **Logic is premade, UI never is.** In a React app, every surface here has a headless hook in `@/commerce/storefront` — `useProductList`, `useProduct` (+ `variantAxes`, `useAddToCartButton`), `useCart`/`useCartLine`/`useCoupon`, `useCheckout` (+ `useAddressForm`, the pickers), `useOrderReturn`, `useProductReviews`, `useStorefrontSeo`. The hooks own the API calls and the branching below and hand you a view-model; **all markup and styling are yours** — nothing in the kit renders UI. Never hand-roll a hook's logic. Framework-free helpers (API client, variant resolution, price and totals rules, free-shipping rules) live in `src/commerce/utils/`.
6
+
7
+ ## Contents
8
+
9
+ - [Required behaviors](#required-behaviors--a-storefront-that-skips-these-cannot-sell) (the four hard rules) · [capabilities](#what-the-api-already-gives-you-read-before-designing-the-ui) · [conventions](#conventions) (envelope, auth, money)
10
+ - [`storefront-catalog`](#commercestorefront-catalog): [`get-store-info`](#get-store-info) · [`list-products`](#list-products) · [`get-product`](#get-product) · [`list-categories`](#list-categories) · [`list-ribbons`](#list-ribbons) · [`list-attributes`](#list-attributes) · [`submit-review`](#submit-review)
11
+ - [`storefront-cart`](#commercestorefront-cart): [priced view](#the-priced-cart-view-returned-by-every-action) · [actions](#actions) · [`cart_token`](#cart_token--the-storefronts-one-job) · [**shipping rules**](#shipping-is-not-optional--read-this-before-building-checkout) · [never advertise unconfigured offers](#never-advertise-what-the-store-isnt-configured-to-do)
12
+ - [`storefront-checkout`](#commercestorefront-checkout): [`place-order`](#place-order) · [`confirm-payment`](#confirm-payment) · [`cancel-order`](#cancel-order)
13
+ - [`payments`](#commercepayments--online-payment-for-an-order): payment links, `complete-return` for `/order-received`
14
+ - [`storefront-account`](#commercestorefront-account): orders, notes, downloads, addresses, my reviews
15
+ - [Walkthrough A — guest checkout](#walkthrough-a--guest-checkout) · [B — member](#walkthrough-b--member-experience) (non-React clients)
16
+ - Entities and direct CRUD → [`entities.md`](./entities.md) · admin surface → [`api-admin.md`](./api-admin.md)
4
17
 
5
18
  ## Required behaviors — a storefront that skips these cannot sell
6
19
 
7
- Read this list before writing any of it. Each item is a hard requirement enforced by the API, not a nicety:
20
+ The canonical statement. Each is enforced by the API, not a nicety:
8
21
 
9
22
  | # | Requirement | Enforced by |
10
23
  |---|---|---|
@@ -14,29 +27,29 @@ Read this list before writing any of it. Each item is a hard requirement enforce
14
27
  | 4 | **Implement the return page** (`/order-received`, or your own route set in Settings → General → *Payment return path*) and call `commerce/payments` `complete-return` there | without it a paid order is never confirmed, and a wrong path is a 404 |
15
28
  | 5 | **Only advertise offers the store is configured for** (free-shipping thresholds come from a location's shipping rates) | nothing — this one is on you |
16
29
 
17
- Four public functions: **`commerce/storefront-catalog`**, **`commerce/storefront-cart`**, **`commerce/storefront-checkout`**, **`commerce/storefront-account`**. All are invoked the same way and return the same envelope.
30
+ Four public functions **`storefront-catalog`**, **`storefront-cart`**, **`storefront-checkout`**, **`storefront-account`** all invoked the same way, all returning the same envelope.
18
31
 
19
32
  ## What the API already gives you (read before designing the UI)
20
33
 
21
- The backend is considerably richer than a minimal "grid → cart → pay" shop. **Build to this surface, not to the basics** — everything below already exists server-side, so leaving it out means shipping a storefront that is weaker than the store behind it. Each row links to the section with the exact payload.
34
+ **Build to this surface, not to the basics** — everything below already exists server-side, so leaving it out ships a storefront weaker than the store behind it.
22
35
 
23
36
  | Capability | Already supported | Where |
24
37
  |---|---|---|
25
- | **Product discovery** | full-text `search`, filter by category (incl. descendants), ribbon, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (the default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
38
+ | **Product discovery** | full-text `search`; filters: category (incl. descendants), ribbon, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
26
39
  | **Rich product pages** | galleries, per-variant price/stock/image, categories, ribbons, **upsells**, **cross-sells** | [`get-product`](#get-product) |
27
- | **Taxonomy navigation** | category tree, **ribbon list with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons), [`list-attributes`](#list-attributes) |
28
- | **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by anyone with an email address** (no login); plus "my reviews" | [`get-product`](#get-product), [`submit-review`](#submit-review), [`my-reviews`](#commercestorefront-account) |
29
- | **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`commerce/storefront-cart`](#commercestorefront-cart) |
30
- | **Coupons** | apply/remove by code with full server-side validation (eligibility, limits, per-user usage) codes are admin-only data, so a store with coupons needs a **code field** somewhere in the buy path (cart or checkout) or they can never be redeemed | [`apply-coupon`](#commercestorefront-cart) |
31
- | **Shipping** | address → matched location → **selectable shipping rates with live costs**, free-over thresholds | [`set-shipping-address`](#commercestorefront-cart) |
32
- | **Tax** | inclusive/exclusive pricing, per-location tax groups, itemized display — all resolved server-side | [`get-store-info`](#get-store-info) |
33
- | **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling, configurable out-of-stock hiding | [`list-products`](#list-products) |
34
- | **Checkout** | guest or authenticated, billing/shipping, per-gateway routing, **payment instructions** for manual methods, order cancel, post-payment confirm | [`commerce/storefront-checkout`](#commercestorefront-checkout) |
35
- | **Accounts** | order history, saved billing/shipping addresses, and **guest order tracking by `order_key`** with customer-visible order notes | [`commerce/storefront-account`](#commercestorefront-account) |
36
- | **Digital products** | entitlement-checked downloads with remaining-count, expiry, and signed URLs for private files | [`get-download`](#commercestorefront-account) |
37
- | **Store config** | currency (format it with `Intl.NumberFormat`), units, catalog/cart price display — read it and honour it instead of hardcoding | [`get-store-info`](#get-store-info) |
38
-
39
- Two things that are **not** in the backend and are yours to build: the visitor UI itself, and only for a store that opts into card payments, which are off by default with no provider shipped — the payment provider integration: four functions in one file ([`post-installation.md` §4.2](../post-installation.md#42-wiring-a-provider--one-file) for the steps, [`references/online-payments.md`](../references/online-payments.md) for the provider code). Payment methods and the currency are admin-owned data — always render them from `get-store-info` rather than a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
40
+ | **Taxonomy navigation** | category tree, **ribbons with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories) · [`list-ribbons`](#list-ribbons) · [`list-attributes`](#list-attributes) |
41
+ | **Customer reviews** | paginated per product with **average rating + count**, `verified` flag, **submission by anyone with an email** (no login), "my reviews" | [`submit-review`](#submit-review) |
42
+ | **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`storefront-cart`](#commercestorefront-cart) |
43
+ | **Coupons** | apply/remove by code, fully validated server-side (eligibility, limits, per-user usage). Codes are admin-only data, so a store with coupons needs a **code field** in the buy path or they can never be redeemed | [`apply-coupon`](#actions) |
44
+ | **Shipping** | address → matched location → **selectable rates with live costs**, free-over thresholds | [`set-shipping-address`](#actions) |
45
+ | **Tax** | inclusive/exclusive pricing, per-location tax groups, itemized display — resolved server-side | [`get-store-info`](#get-store-info) |
46
+ | **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling, out-of-stock hiding | [`list-products`](#list-products) |
47
+ | **Checkout** | guest or authenticated, billing/shipping, per-gateway routing, **payment instructions** for manual methods, cancel, post-payment confirm | [`storefront-checkout`](#commercestorefront-checkout) |
48
+ | **Accounts** | order history, saved addresses, **guest order tracking by `order_key`** with customer-visible notes | [`storefront-account`](#commercestorefront-account) |
49
+ | **Digital products** | entitlement-checked downloads with remaining-count, expiry, signed URLs for private files | [`get-download`](#commercestorefront-account) |
50
+ | **Store config** | currency, units, catalog/cart price display — honour it instead of hardcoding | [`get-store-info`](#get-store-info) |
51
+
52
+ Not in the backend: the visitor UI (all of it is yours the hooks above carry the logic), and the card-payment provider integration, which only a store that opts into cards needs cards are off by default, the decision and its timing live in [`../install/03-data.md`](../install/03-data.md), the provider code in [`../references/online-payments.md`](../references/online-payments.md). Payment methods and the currency are admin-owned data — always render them from `get-store-info`, never a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
40
53
 
41
54
  ## Conventions
42
55
 
@@ -45,8 +58,8 @@ Two things that are **not** in the backend and are yours to build: the visitor U
45
58
  const res = await base44.functions.invoke("commerce/storefront-catalog", { action: "get-store-info" });
46
59
  const info = res.data.data; // envelope → payload
47
60
  ```
48
- - **Auth modes:** most actions are anonymous. Some require an authenticated Base44 session (marked **auth**). Guest order access uses `order_key`; cart access uses `cart_token` — both are bearer credentials (HTTPS only).
49
- - **Identity is never a payload field.** Anything attributed to a person — a review's author, a saved profile — is taken from the session. Passing an email in the body does not make you that customer, and no request can assert that an order was paid. Don't design a feature around a client-supplied identity; it will be ignored.
61
+ - **Auth modes:** most actions are anonymous. Some require an authenticated Base44 session (marked **auth**). Two bearer credentials (HTTPS only): `order_key` for guest order access, `cart_token` for the cart whose lifecycle rules are [in the cart section](#cart_token--the-storefronts-one-job).
62
+ - **Identity is never a payload field.** Anything attributed to a person — a review's author, a saved profile — comes from the session. An email in the body does not make you that customer, and no request can assert that an order was paid. A feature built on client-supplied identity will be ignored.
50
63
  - **Money:** numbers, 2-dp. **Dates:** ISO strings.
51
64
  - **Errors:** every failure has a stable `code` (listed per action) plus a human `error` message.
52
65
 
@@ -72,9 +85,9 @@ Bootstrap data for a storefront. No payload.
72
85
  "currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
73
86
  }
74
87
  ```
75
- `settings` is a safe projection — only display/behavior keys, never admin config.
88
+ `settings` is a safe projection — display/behavior keys only, never admin config.
76
89
 
77
- `payment_gateways` is **every gateway the admin has enabled** (sorted by their `order`) the Payments settings switch is the single source of truth, so render this list and never a hardcoded one. The sample above shows both shapes, but a **default-seeded store reports `offline` alone**: cards are off by default and appear only once the gateway is enabled which should only happen with a provider wired ([`post-installation.md` §4.1](../post-installation.md#41-off-by-default--enable-only-with-a-provider-wired)) so expect the one-method case in a fresh store. `online: true` marks the card option (`place-order` answers with a payment page to redirect to; `503 no_card_payment_provider` if it was enabled with no provider behind it); everything else settles manually.
90
+ `payment_gateways` is **every gateway the admin has enabled** (sorted by `order`) and this call is its **only** source `cart.payment_gateways` does not exist, and a hardcoded list is a broken checkout. A **default-seeded store reports `offline` alone** (cards are off by default — [`../install/03-data.md`](../install/03-data.md)), so expect the one-method case. `online: true` marks the card option (`place-order` answers with a payment page; `503 no_card_payment_provider` if it was enabled with no provider behind it); everything else settles manually. In React: `useStoreInfo()` + `PaymentMethodPicker`.
78
91
 
79
92
  ### `list-products`
80
93
  **Payload** (all optional): `search`, `category_id` (includes descendants), `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (`-created_date`|`name`|`price`|`-price`|`popularity`|`rating`, default `-created_date`), `page` (default 1), `per_page` (default 12, max 100).
@@ -83,25 +96,21 @@ Only `status: "publish"` products are returned — the admin's single **Visible*
83
96
 
84
97
  **Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
85
98
 
86
- Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards can show their ribbons without a second call. `categories` are **not** resolved on rows — `category_ids` only.
99
+ Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only. What a row can and can't show, and how to add a `get-product`-only field to this call instead of fetching per card: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
87
100
 
88
- > **What to render, and what a row can't show:** [`../references/product-render.md`](../references/product-render.md) — field-availability table for list vs. product page, ribbons in both views, variable-product pricing on cards, and how to add a `get-product`-only field to the listing call instead of fetching per card.
101
+ **Filters stack** all of them are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
89
102
 
90
- **Filters stack.** `category_id`, `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`/`max_price`, `on_sale`, `featured` and `in_stock_only` are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
91
-
92
- **The same call also serves a short strip anywhere else** — a homepage hero or banner, a "featured" row, a "new in" rail, related picks beside an article. `per_page` bounds the result and the filters/sort choose what lands in it, so a small curated slice is one request; there is no separate endpoint, and no reason to fetch a big page and slice it client-side. Available if a page wants one:
103
+ **The same call serves every short strip** homepage hero, "featured" row, "new in" rail, related picks. `per_page` bounds it and the filters/sort choose what lands in it, so a curated slice is one request; there is no separate endpoint and no reason to fetch a big page and slice client-side:
93
104
 
94
105
  ```js
95
- const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
96
106
  const cat = (payload) => inv("commerce/storefront-catalog", { action: "list-products", ...payload });
97
-
98
- const { products: featured } = await cat({ featured: true, per_page: 4 });
99
- const { products: newest } = await cat({ sort: "-created_date", per_page: 6 });
100
- const { products: deals } = await cat({ on_sale: true, in_stock_only: true, per_page: 4 });
101
- const { products: topInCat } = await cat({ category_id, sort: "popularity", per_page: 4 });
107
+ await cat({ featured: true, per_page: 4 }); // curated: the admin's own star flag
108
+ await cat({ sort: "-created_date", per_page: 6 }); // new in
109
+ await cat({ on_sale: true, in_stock_only: true, per_page: 4 }); // deals
110
+ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in category
102
111
  ```
103
112
 
104
- `featured` is the admin's own per-product flag (the star in the products list, *Featured product* in the editor's Publish box; `commerce/seed-store` accepts `featured: true` per product), so a featured strip is curated store data rather than slugs pinned in the UI. Rows are the ordinary listing rows — same fields a grid card renders ([`../references/product-render.md`](../references/product-render.md)). **A filter may legitimately match nothing** (no product starred, nothing discounted), so drive the block off the returned array's length hide it or fall back to another slice instead of assuming rows came back; `has_next` tells you whether a "see all" link has more behind it.
113
+ `featured` is the admin's per-product flag (`commerce/seed-store` accepts `featured: true`), so a featured strip is store data, not slugs pinned in the UI. **A filter may legitimately match nothing** (nothing starred, nothing discounted) drive the block off the returned array's length, hiding it or falling back to another slice, rather than assuming rows came back; `has_next` says whether a "see all" has more behind it. In React this is `useProductList`.
105
114
 
106
115
  ### `get-product`
107
116
  **Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
@@ -122,19 +131,15 @@ const { products: topInCat } = await cat({ category_id, sort: "popularity", per_
122
131
  ```
123
132
  **Errors:** `404 not_found` (missing / not published / hidden).
124
133
 
125
- > **Rendering this response as a product page — the rule, not just a link.** `variations[]` is **not** a list of choices to show. Build **one control per `product.attributes[]` entry** (Size, Color, …) — every attribute is an axis — and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variant prices come from `variations[]`, never from `product.price` (the backend rolls stock up to the parent, not price). The shipped helper does all of it:
134
+ > **`variations[]` is not a list of choices to show.** Build **one control per `product.attributes[]` entry** (Size, Color, …) — every attribute is an axis — and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variant prices come from `variations[]`, never from `product.price` (the parent's price is a rolled-up from-price). A React page gets all of this from `useProduct` + `useAddToCartButton` (map `variantAxes(view, pick)` into your controls, gate on `view.purchasable`); a non-React client uses the framework-free resolver:
126
135
  > ```js
127
136
  > import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
128
- > const [selection, setSelection] = useState(() => defaultSelection(product, variations));
129
- > const view = resolveSelection(product, variations, selection);
130
- > // view.axesrender a control per axis, options from axis.options
131
- > // view.availability[axis.key][option] → "available" | "out_of_stock" | "unavailable" (disable, don't hide)
132
- > // view.display → image / price / sku / stock for the resolved variation
137
+ > const view = resolveSelection(product, variations, selection); // selection from defaultSelection(), updated via selectOption()
138
+ > // view.axes one control per axis view.display → image/price/sku/stock of the resolved variation
139
+ > // view.availability[axis.key][option]"available" | "out_of_stock" | "unavailable" (disable, don't hide)
133
140
  > // view.purchasable + view.addToCart → { product_id, variation_id } for add-item
134
- > const pick = (axisKey, option) => setSelection((s) => selectOption(product, variations, s, axisKey, option));
135
141
  > ```
136
- > Full variant rules (availability states, incomplete-selection pricing, defaults, URL state): [`../references/storefront-product-page.md`](../references/storefront-product-page.md).
137
- > What to render on the page vs. a card, and which fields exist in only one of the two calls: [`../references/product-render.md`](../references/product-render.md).
142
+ > Field availability per view, ribbons, from-price rules and the variant deep-dive (availability states, incomplete-selection pricing, URL round-trip): [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
138
143
 
139
144
  ### `list-categories`
140
145
  No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
@@ -144,19 +149,19 @@ No payload. Returns a nested tree: `{ "categories": [ { ...category, "children":
144
149
 
145
150
  Returns `{ "ribbons": [ { id, name, count } ] }` sorted by name. By default it omits ribbons no published product carries, so a ribbon nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
146
151
 
147
- `count` is tallied from the products this API would actually list published and honoring `inventory.hide_out_of_stock` — **not** from `ProductRibbon.count`, which also counts drafts and drifts until `commerce/admin-tools` `recount-terms` runs. So `Gift (12)` and the ribbon's listing agree.
152
+ `count` is tallied from the products this API would actually list (published, honoring `inventory.hide_out_of_stock`) — **not** from `ProductRibbon.count`, which also counts drafts and drifts until `admin-tools` `recount-terms` runs. So `Gift (12)` and the ribbon's listing agree.
148
153
 
149
- This is the **only** way a storefront can enumerate ribbons `commerce.ProductRibbon` is admin-only RLS — and it's what makes `list-products` `ribbon_id` usable, since that filter needs an id. Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Show both: see [*Ribbons*](../references/product-render.md#2-ribbons--render-them-in-both-views) for where they belong in each view.
154
+ This is the **only** way a storefront can enumerate ribbons (the entity is admin-only) and what makes `list-products` `ribbon_id` usable, since that filter needs an id. Ribbons are flat, cross-cutting labels; categories are the hierarchical spine show both ([`../references/catalog-rendering.md`](../references/catalog-rendering.md)). In React: `useRibbons` / `useCategories`.
150
155
 
151
156
  ### `list-attributes`
152
- No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — the whole attribute record (`id, name, code, order`) plus its values (`id, attribute_id, name, order, count`), attributes sorted by `order` and each attribute's values by their own `order` — for building filter UIs. Filter with `list-products` `attribute_id` (the id, or the attribute **name**) + `attribute_term` (the value name); `code` is the stable key to put in a URL.
157
+ No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — each attribute (`id, name, code, order`) with its values (`id, attribute_id, name, order, count`), both sorted by `order`; for filter UIs. Filter with `list-products` `attribute_id` (id or attribute **name**) + `attribute_term` (the value name); `code` is the stable key for a URL.
153
158
 
154
159
  ### `submit-review`
155
160
  **Payload:** `{ product_id, reviewer?, review, rating }`.
156
161
 
157
- **Public by default: anyone can review with an email address — no login.** A guest passes `email` in the payload; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only — it defaults to the account's `full_name`, then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history (`processing`/`completed` order containing the product). Status is `hold` unless `products.auto_approve_reviews` — the one server-side review switch.
162
+ > A React storefront gets the whole reviews surface this call, the paginated list from `get-product`, refresh, and the store's moderation policy from **`useProductReviews`**; don't hand-roll it. Read on for the raw contract.
158
163
 
159
- Any stricter policy is the **storefront's** to enforce in its UI — login-gated forms, verified-buyers-only, a required rating: see [`../references/reviews.md`](../references/reviews.md) for the patterns.
164
+ **Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are a `policy` option on the hook, not code you write: [`../references/reviews.md`](../references/reviews.md).
160
165
 
161
166
  **Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
162
167
  **Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
@@ -165,7 +170,7 @@ Any stricter policy is the **storefront's** to enforce in its UI — login-gated
165
170
 
166
171
  ## commerce/storefront-cart
167
172
 
168
- Token-scoped cart (guest + member). Every action **except `create`** takes `cart_token`. **Every mutating action returns the full priced cart view.** Carts have a rolling 48h TTL (refreshed on each touch). An authenticated caller's other active carts are merged in (quantities summed, `sold_individually` capped, coupon codes unioned; source carts marked abandoned).
173
+ Token-scoped cart (guest + member). Every action **except `create`** takes `cart_token`, and **every mutating action returns the full priced cart view**. Rolling 48h TTL, refreshed on each touch. An authenticated caller's other active carts are merged in (quantities summed, `sold_individually` capped, coupon codes unioned; source carts marked abandoned).
169
174
 
170
175
  ### The priced cart view (returned by every action)
171
176
  ```json
@@ -193,7 +198,7 @@ Token-scoped cart (guest + member). Every action **except `create`** takes `cart
193
198
  "expires_at": "2025-..."
194
199
  }
195
200
  ```
196
- Stored coupons that stop validating are **auto-removed** and reported in `coupon_notices`; items whose product vanished/unpublished appear in `removed_items`.
201
+ Coupons that stop validating are **auto-removed** and reported in `coupon_notices`; items whose product vanished or was unpublished appear in `removed_items`. In React `useCart` exposes this view as `lines` + `notices`.
197
202
 
198
203
  ### Actions
199
204
  | Action | Payload | Notes / errors |
@@ -201,17 +206,22 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
201
206
  | `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
202
207
  | `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
203
208
  | `totals` | `{ cart_token }` | Alias of `get`. |
204
- | `add-item` | `{ cart_token?, product_id, variation_id?, quantity?, attributes? }` | **Self-healing:** a missing, unknown or expired `cart_token` starts a fresh cart and adds the item to it — the response carries the new `cart_token`, so **always persist the token from the response** (see the note below the table). A product carrying attributes requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
209
+ | `add-item` | `{ cart_token?, product_id, variation_id?, quantity?, attributes? }` | **Self-healing:** a missing, unknown or expired `cart_token` starts a fresh cart and adds the item to it ([token rules](#cart_token--the-storefronts-one-job)). A product carrying attributes requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
205
210
  | `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
206
211
  | `remove-item` | `{ cart_token, item_key }` | |
207
212
  | `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
208
213
  | `remove-coupon` | `{ cart_token, code }` | |
209
- | `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched location's `available_shipping_methods` and a `shipping_status`. **Fails immediately with `400 shipping_not_available`** when the store doesn't ship to the address (the error body carries the priced `cart`) the caller learns on address entry, not at place-order. |
210
- | `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a shipping rate `id` from `available_shipping_methods`. `400 invalid_shipping_method` (the error body carries `available_shipping_methods`). |
214
+ | `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | Returns the matched location's `available_shipping_methods` + `shipping_status`. `400 country_required`; `400 shipping_not_available` when the store doesn't ship there (error body carries the priced `cart`). [Rules ↓](#shipping-is-not-optional--read-this-before-building-checkout) |
215
+ | `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a shipping rate `id` from `available_shipping_methods`. `400 invalid_shipping_method` (error body carries `available_shipping_methods`). |
211
216
 
212
217
  Common: `400 cart_token_required`, `404 cart_not_found`, `404 cart_expired` — except `add-item`, which never fails on the token: it starts a fresh cart instead.
213
218
 
214
- > **Handle your cart_token cache.** Cart tokens expire (48h rolling TTL) and carts are consumed at checkout, so a token cached in `localStorage` can go stale. `add-item` heals this by starting a fresh cart but only if the storefront **reads `cart_token` from every cart response and re-persists it** instead of assuming the stored one survived. Do that after every cart call, and treat `404 cart_not_found|cart_expired` from the other actions as "clear the cached token and start over" rather than an error to show.
219
+ ### `cart_token` — the storefront's one job
220
+ <a id="cart_token--the-storefronts-one-job"></a>
221
+
222
+ The token is the cart's bearer credential (treat it as a secret) and the **only** cart state a client keeps. Tokens expire (48h rolling TTL) and carts are consumed at checkout, so one cached in `localStorage` can go stale. `add-item` heals that by starting a fresh cart — but only if the client **reads `cart_token` from every cart response and re-persists it** instead of assuming the stored one survived. Do that after every cart call, and treat `404 cart_not_found|cart_expired` from the other actions as "clear the cached token and start over", not as an error to show.
223
+
224
+ **In React none of this is yours:** `StorefrontProvider` owns the token and `useCart` exposes the priced view — never read, store or send the token yourself.
215
225
 
216
226
  ### Shipping is not optional — read this before building checkout
217
227
  <a id="shipping-is-not-optional--read-this-before-building-checkout"></a>
@@ -222,40 +232,42 @@ rather than assuming:
222
232
 
223
233
  | `shipping_status` | Meaning | What the storefront must do |
224
234
  |---|---|---|
225
- | `not_needed` | every line is virtual, or shipping is disabled store-wide | show no shipping step |
226
- | `auto_selected` | exactly **one** method is offered, so the cart already applied it | show it as the (only) delivery option — **don't** make the customer pick from a list of one |
235
+ | `not_needed` | every line is virtual, or shipping is off store-wide | show no shipping step |
236
+ | `auto_selected` | exactly **one** method offered, already applied | show it as the only delivery option — **don't** make the customer pick from a list of one |
227
237
  | `chosen` | the customer's pick is still offered | show it, allow changing |
228
- | `choice_required` | **several** methods are offered and none is chosen | render `available_shipping_methods` and call `choose-shipping-method`; checkout will refuse until then |
229
- | `missing_address` | the options can't be determined until an address is set (several locations defined, none yet matchable) | collect the address and call `set-shipping-address` — shipping cost is not calculated yet |
230
- | `none_available` | nothing ships to this address | say so, and don't let checkout proceed (`set-shipping-address` already failed `shipping_not_available` when this address was set) |
238
+ | `choice_required` | **several** offered, none chosen | render `available_shipping_methods`, call `choose-shipping-method` checkout refuses until then |
239
+ | `missing_address` | options undeterminable until an address is set (several locations, none matchable yet) | collect the address, call `set-shipping-address`; cost is not calculated yet |
240
+ | `none_available` | nothing ships to this address | say so and block checkout (`set-shipping-address` already failed `shipping_not_available`) |
231
241
 
232
242
  Rules the cart enforces on every price, so you get them for free:
233
243
 
234
- - **A single available method is auto-selected.** One option is not a choice; requiring a round trip for it is exactly how a storefront ends up never sending a method at all.
235
- - **A single location answers before the address.** A store with exactly **one** Shipping & Tax Location gets that location's rates (and, via auto-select, a shipping cost) on the cart with no address set — its options are the same everywhere. With several locations the cart reports `missing_address` until `set-shipping-address` resolves one.
236
- - **An unsupported address fails on entry.** `set-shipping-address` returns `400 shipping_not_available` the moment a validated address matches no location (e.g. the store's one location doesn't cover it) — surface it on the address form instead of letting the customer reach payment.
237
- - **A stale choice is dropped.** If the address or cart changes so the chosen method is no longer offered, it is cleared (and re-auto-selected when only one remains) instead of silently pricing zero shipping.
238
- - **Costs change with the cart.** `available_shipping_methods` is recomputed per price — free-shipping thresholds and coupon-gated methods appear and disappear so **call `set-shipping-address` as soon as the customer provides an address and re-read the returned cart after every mutation**; never carry an options list you fetched earlier.
244
+ - **A single available method is auto-selected.** One option is not a choice, and requiring a round trip for it is how a storefront ends up never sending a method at all.
245
+ - **A single location answers before the address.** A store with exactly **one** location gets its rates (and, via auto-select, a cost) with no address set — the options are the same everywhere. With several, the cart reports `missing_address` until `set-shipping-address` resolves one.
246
+ - **An unsupported address fails on entry**, with `400 shipping_not_available` — surface it on the address form instead of letting the customer reach payment.
247
+ - **A stale choice is dropped.** If the address or cart changes so the chosen method is no longer offered, it is cleared (and re-auto-selected when one remains) rather than silently pricing zero shipping.
248
+ - **Costs change with the cart** — free-shipping thresholds and coupon-gated methods appear and disappear, so **call `set-shipping-address` as soon as an address exists and re-read the cart after every mutation**; never carry an options list you fetched earlier.
249
+
250
+ What `place-order` does with all this: a single offered method is applied for you; several offered with none chosen is `400 shipping_method_required` (with `available_shipping_methods` in the error body); a `chosen_shipping_method` not offered for the address is `400 invalid_shipping_method`, never silently swapped; nothing offered is `400 no_shipping_available`. No order is created in any of those cases, and the order that is created prices the resolved method, so its `shipping_total` always matches what the customer was shown.
251
+
252
+ **In React this is `useCheckout`**: address state debounces into `set-shipping-address`, `ShippingMethodPicker` renders the options, and `canPlaceOrder` blocks with a named blocker until a method resolves — the whole table above is the hook's behavior, not code you write. Location/zone/tax configuration: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
239
253
 
240
254
  ### Never advertise what the store isn't configured to do
241
255
 
242
- Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and the checkout will only honour what's actually configured. Don't write such a line into a storefront unless a matching rule exists — either configure it (with the operator's agreement) or leave the copy out.
256
+ Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and checkout honours only what is configured. Don't write such a line unless a matching rule exists — configure it (with the operator's agreement) or leave the copy out. The same goes for a discount claim (it needs a real `commerce.Coupon`, and only a code the operator gave you) and for delivery-time or returns promises the template cannot enforce.
243
257
 
244
- **Locations are admin-only data.** `commerce.ShippingTaxLocation` is `read: {role: "admin"}` like every entity, so a visitor cannot list them — do not try. Two honest sources:
258
+ **Locations are admin-only data** like every entity, so a visitor cannot list them — do not try. Two honest sources:
245
259
 
246
- 1. **The cart itself**, which is the live answer for a real address: after `set-shipping-address`, `available_shipping_methods` already reflects every rule the checkout will honour, free shipping included. Prefer this.
247
- 2. **A projection you add**, if you want a threshold banner *before* an address exists: widen `get-store-info` (or add an action) to return the free-shipping rules, then normalize them with [`src/commerce/utils/shipping-promos.js`](../../../src/commerce/utils/shipping-promos.js):
260
+ 1. **The cart** the live answer for a real address: after `set-shipping-address`, `available_shipping_methods` reflects every rule checkout will honour, free shipping included. Prefer this.
261
+ 2. **A projection you add**, for a threshold banner *before* an address exists: widen `get-store-info` (or add an action) to return the free-shipping rules, then normalize them:
248
262
 
249
263
  ```js
250
264
  import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
251
-
252
- // locations come from your own storefront action NOT from a client entity read
253
- const threshold = freeShippingThreshold(freeShippingRules(locations));
254
- // null → the store has no free-shipping rule: show no banner, no progress bar.
265
+ // locations come from your own storefront action — NOT a client entity read
266
+ const threshold = freeShippingThreshold(freeShippingRules(locations)); // null no rule: no banner
255
267
  const progress = freeShippingProgress(threshold, cart.totals.subtotal - cart.totals.discount_total);
256
268
  ```
257
269
 
258
- A free-shipping rule is a shipping rate that is free (`cost: 0`) or carries a `free_over` threshold (thresholds are **per location** — a rule in one location must not become a site-wide banner). The same applies to any other promise: a discount claim needs a real `commerce.Coupon` (admin-read only — only advertise a code the operator gave you), and delivery-time or returns claims the template cannot enforce need the operator's confirmation.
270
+ A free-shipping rule is a rate that is free (`cost: 0`) or carries a `free_over` threshold. Thresholds are **per location** — a rule in one location must not become a site-wide banner.
259
271
 
260
272
  ---
261
273
 
@@ -272,31 +284,13 @@ Converts a cart into an order. **Payload:**
272
284
  ```
273
285
  **Mandatory fields:**
274
286
  - **Billing:** `first_name, last_name, address_1, city, country, email`.
275
- - **Shipping address** and a **shipping method** — mandatory whenever the cart has any non-virtual (physical) line and shipping is enabled. Your checkout UI has to *offer* the options; there is no default. The whole contract:
276
- ```js
277
- // the moment the customer provides an address — this recalculates the options and cost;
278
- // an unsupported address fails right here with 400 shipping_not_available
279
- cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token, address });
280
- if (cart.shipping_status === "choice_required") {
281
- // MUST render cart.available_shipping_methods and let the customer pick
282
- cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token, method_id: picked.id });
283
- }
284
- // auto_selected / chosen: nothing to do, one option was applied for you
285
- // (a single-location store may show auto_selected before any address is set)
286
- ```
287
- Skipping this is the most common storefront bug: `place-order` answers `400 shipping_method_required` with the methods attached, and no order is created. Set the address on the cart first (`set-shipping-address`); the method can come either from the cart (`choose-shipping-method`) or straight from this call via `chosen_shipping_method`, which wins if both are present. What happens then:
288
- - **one method offered** → applied automatically, nothing to send;
289
- - **several offered, none chosen** → `400 shipping_method_required`, with `available_shipping_methods` in the error body so you can prompt;
290
- - **`chosen_shipping_method` not offered** for this address → `400 invalid_shipping_method` (never silently swapped for another);
291
- - **nothing offered** → `400 no_shipping_available`.
292
-
293
- The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
287
+ - **Shipping address** and a **shipping method** whenever the cart has any non-virtual line and shipping is enabled. Set the address on the cart first (`set-shipping-address`); the method comes either from the cart (`choose-shipping-method`) or from this call's `chosen_shipping_method`, which wins if both are present. Skipping it is the most common storefront bug — the rules, the `shipping_status` branches and the four error codes are in [**Shipping is not optional**](#shipping-is-not-optional--read-this-before-building-checkout).
294
288
 
295
- > **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Choosing the **card** gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, reference }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `complete-return`. Never treat a return URL as proof of payment. While the store has no payment provider implemented the card gateway fails with `503 no_card_payment_provider` — tell the customer card payment is unavailable and offer the other methods. **Every other gateway is manual** (offline, and any option the admin added in Settings → Payments): the order goes on-hold and `payment_instructions` carries the gateway's description and bank accounts. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
289
+ > **Payment.** `payment_method` is a gateway slug from [`get-store-info`](#get-store-info). The **card** gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, reference }` — redirect to `checkout_url`, then confirm on the return with [`complete-return`](#commercepayments--online-payment-for-an-order); a return URL is never proof of payment. **Every other gateway is manual** (offline, plus anything the admin added): the order goes on-hold and `payment_instructions` carries the gateway's description and bank accounts. `success_url`/`cancel_url`/`return_url` override the default return URLs.
296
290
 
297
- **Steps:** releases expired holds → revalidates stock & coupons → computes authoritative totals → finds-or-creates the Customer by billing email → creates a `pending` order (with `order_key`, `hold_expires_at`) → reduces stock, fires `new_order` email + `order.created` webhook → marks cart `converted` → routes by gateway: **card stays pending** until the hosted page is paid, **everything else on-hold** (with `payment_instructions`).
291
+ **Steps:** release expired holds → revalidate stock & coupons → authoritative totals → find-or-create the Customer by billing email → create a `pending` order (`order_key`, `hold_expires_at`) → reduce stock, fire `new_order` email + `order.created` webhook → mark the cart `converted` → route by gateway: **card stays pending** until the hosted page is paid, **everything else goes on-hold** with `payment_instructions`.
298
292
 
299
- > **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with. The **`commerce.Customer`** record behind it — the one a storefront prefills from is refreshed only when the caller is signed in *as* the billing email. A guest checkout quoting an existing customer's address still attaches the order to them (so it shows up in their `my-orders`), but cannot change their saved name or addresses. Don't build an "edit my details at checkout" flow for guests; use `update-my-addresses` behind a login instead.
293
+ > **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with, but the `commerce.Customer` record a storefront prefills from is refreshed only when the caller is signed in *as* the billing email. A guest checkout quoting an existing customer's address still attaches the order to them (it shows up in their `my-orders`) without changing their saved details so no "edit my details at checkout" for guests; use `update-my-addresses` behind a login.
300
294
 
301
295
  **Response:**
302
296
  ```json
@@ -312,31 +306,26 @@ Converts a cart into an order. **Payload:**
312
306
  "order": { ...customer-safe order (internal flags/ip stripped) }
313
307
  }
314
308
  ```
315
- **Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`, `400 shipping_method_required` / `400 invalid_shipping_method` / `400 no_shipping_available` (each carrying `available_shipping_methods`; these replace the earlier catch-all `shipping_required`), `400 invalid_payment_method`, `503 no_card_payment_provider` (card gateway chosen but no provider is implemented — the order exists as `pending` and its stock hold expires on its own; offer another method).
309
+ **Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`, the three shipping codes above (each carrying `available_shipping_methods`), `400 invalid_payment_method`, `503 no_card_payment_provider` (card chosen, no provider implemented — the order exists as `pending`, its stock hold expires on its own; offer another method).
316
310
 
317
311
  ### `commerce/payments` — online payment for an order
318
312
 
319
- Separate function, same guest-bearer rule (`order_id` + `order_key`; an admin may act without a key).
313
+ Separate function, same guest-bearer rule: `order_id` + `order_key` (an admin may act without a key). The admin-side view of these actions is in [`api-admin.md`](./api-admin.md#commercepayments).
320
314
 
321
315
  | Action | Payload | Returns |
322
316
  |---|---|---|
323
- | `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ url, reference }` — a fresh hosted payment page for an **unpaid** order, whatever its current payment method (the order is switched onto the card gateway and the change is logged). This is how a storefront offers "pay now" on an order-received page or an emailed link, and what the admin's payment-link button calls. `409 already_paid`, `400 card_payments_disabled` (the store turned card payment off), `503 no_card_payment_provider` |
324
- | `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link, payment_instructions }` — the whole return flow in one call for your `/order-received` page: confirms with the provider, progresses the order, includes a fresh `payment_link: { url, reference }` while unpaid (card orders), and re-supplies `payment_instructions: { type, description, account_details }` for unpaid **manual** orders so the page can render bank details on every visit. `payment` is only a hint; a hand-edited `?payment=success` can never yield `paid`. **Note:** `order` carries flat totals (`order.total`, `order.shipping_total`) there is no `order.totals` object; that nested shape belongs to the cart view |
325
- | `verify` | `{ order_id, order_key }` | `{ paid, already_confirmed, status, order }` — the same confirmation without the render-ready extras. **Idempotent**: call it on every return, and as often as you like. Whether money arrived is asked of the provider about the payment reference stored on the order |
317
+ | `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ url, reference }` — a fresh hosted payment page for an **unpaid** order whatever its current method (it is switched onto the card gateway, logged): "pay now" on an order-received page or an emailed link. `409 already_paid`, `400 card_payments_disabled`, `503 no_card_payment_provider` |
318
+ | `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link, payment_instructions }` — the whole return flow in one call: confirms with the provider, progresses the order, adds a fresh `payment_link: { url, reference }` while a card order is unpaid, and re-supplies `payment_instructions: { type, description, account_details }` for unpaid **manual** orders so bank details render on every visit. `payment` is only a hint a hand-edited `?payment=success` can never yield `paid`. **`order` carries flat totals** (`order.total`, `order.shipping_total`); there is no `order.totals` that shape belongs to the cart view |
319
+ | `verify` | `{ order_id, order_key }` | `{ paid, already_confirmed, status, order }` — the same confirmation without the render-ready extras. **Idempotent**; money is asked of the provider about the reference stored on the order |
326
320
 
327
- > **You must build the `/order-received` page** — it is mandatory for payment links to work. No storefront UI ships with the template, and without that route a paying customer lands on a 404 *and* the order never gets marked paid. It only needs to call `complete-return` and render its three states: see [`../references/online-payments.md`](../references/online-payments.md).
321
+ > **The `/order-received` route is mandatory** (requirement 4): without it a paying customer lands on a 404 *and* the order is never marked paid. It only has to call `complete-return` and render its three states `useOrderReturn` does exactly that (your markup renders the states, including `paymentInstructions`).
328
322
 
329
- Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab. Whichever path runs second is a no-op. The webhook function is premade (it verifies through the provider's API, so no signing secret); wiring a payment provider means implementing four functions in one file — see [`../references/online-payments.md`](../references/online-payments.md).
323
+ Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab; whichever path runs second is a no-op. That function is premade (it verifies through the provider's API, so no signing secret). Wiring a provider: [`../references/online-payments.md`](../references/online-payments.md).
330
324
 
331
325
  ### `confirm-payment`
332
- Post-payment hook for an order on the card gateway. **Payload:** `{ order_id, order_key }`.
326
+ The bare transition for a card order `complete-return` is the richer version and what `/order-received` should call. **Payload:** `{ order_id, order_key }` → `{ order, paid, already_confirmed }`.
333
327
 
334
- **The request cannot make an order paid.** The `order_key` says who is asking; the payment itself is confirmed against the provider using the payment reference stored on the order. Only then does the order move `pending`/`on-hold` → `processing`, set `date_paid` and bump customer stats. There is no caller-supplied `transaction_id` it comes from the provider. Idempotent.
335
-
336
- `commerce/payments` `complete-return` is the richer version of this and what an `/order-received` page should call; use `confirm-payment` when you only need the transition.
337
-
338
- **Response:** `{ "order": { ...customer-safe order }, "paid": true, "already_confirmed": false }`
339
- **Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
328
+ **The request cannot make an order paid.** The `order_key` says who is asking; payment is confirmed against the provider using the reference stored on the order. Only then does it move `pending`/`on-hold` → `processing`, set `date_paid` and bump customer stats. No caller-supplied `transaction_id`. Idempotent. **Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
340
329
 
341
330
  ### `cancel-order`
342
331
  Customer-initiated cancel. **Payload:** `{ order_id, order_key }`. Only `pending`/`on-hold` (restores stock). **Response:** `{ "order": { ...customer-safe order } }` · **Errors:** `400 order_key_required`, `404 order_not_found`, `409 invalid_status`.
@@ -353,28 +342,30 @@ Two access modes: **auth** (Base44 session) or **`order_key` bearer** (guest tra
353
342
  | `get-order` | order_key | `{ order_id, order_key }` | `{ order: customer-safe }` |
354
343
  | `order-notes` | order_key | `{ order_id, order_key }` | `{ notes: [{ id, note, created_date }] }` (customer notes only) |
355
344
  | `my-downloads` | auth | — | `{ downloads: [{ permission_id, order_id, product_id, download_name, downloads_remaining, access_expires, download_count }] }` |
356
- | `get-download` | auth **or** order_key | `{ permission_id, order_key? }` | `{ url, download_name, downloads_remaining }` — decrements remaining; returns a signed URL for private files |
345
+ | `get-download` | auth **or** order_key | `{ permission_id, order_key? }` | `{ url, download_name, downloads_remaining }` — decrements remaining, enforces `access_expires` |
357
346
  | `update-my-addresses` | auth | `{ billing?, shipping? }` | `{ customer: { email, first_name, last_name, billing, shipping } }` |
358
347
  | `my-reviews` | auth | — | `{ reviews: [{ id, product_id, review, rating, status, verified, created_date }] }` |
359
348
 
360
349
  **Errors:** `401 login_required`, `400 order_key_required|permission_required|nothing_to_update`, `404 order_not_found|download_not_found`, `403 forbidden|download_limit_reached|download_expired`.
361
350
 
351
+ > **Public vs. private files.** A download's `file_url` is whatever the admin stored: `Core.UploadFile` gives a public URL, handed back as-is. For files that must not be guessable, upload with **`Core.UploadPrivateFile`** — it stores a `file_uri` rather than an `http` URL, and `get-download` detects the non-`http` scheme and returns a short-lived **signed URL** (`Core.CreateFileSignedUrl`, 1h) instead. Either way the entitlement check, the `downloads_remaining` decrement and `access_expires` are enforced here — never link a customer straight to a stored URL.
352
+
362
353
  ---
363
354
 
364
355
  ## Walkthrough A — guest checkout
365
356
 
357
+ Raw-call sequence for a non-React client (a React app gets this from `useCart` + `useCheckout`).
358
+
366
359
  ```js
367
360
  const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
368
361
 
369
- // 1. Browse
362
+ // 1. Browse, then 2. open a cart with one item
370
363
  const { products } = await inv("commerce/storefront-catalog", { action: "list-products", per_page: 12 });
371
-
372
- // 2. New cart with one item
373
364
  let cart = await inv("commerce/storefront-cart", { action: "create",
374
365
  items: [{ product_id: products[0].id, quantity: 1 }] });
375
- const token = cart.cart_token;
366
+ const token = cart.cart_token; // re-read it from every response
376
367
 
377
- // 3. (product with variants) fetch options, then add the chosen variation
368
+ // 3. (variants) fetch the options, then add the chosen variation
378
369
  const detail = await inv("commerce/storefront-catalog", { action: "get-product", id: products[0].id });
379
370
  if ((detail.product.attributes ?? []).length) {
380
371
  cart = await inv("commerce/storefront-cart", { action: "add-item",
@@ -384,10 +375,7 @@ if ((detail.product.attributes ?? []).length) {
384
375
  // 4. Coupon (optional)
385
376
  cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token: token, code: "welcome10" });
386
377
 
387
- // 5. Shipping address method. Call this as soon as the address is known — it
388
- // recalculates the options and cost; an unsupported address fails right here
389
- // (400 shipping_not_available). A single option is already applied for you;
390
- // several mean the customer has to choose or checkout will refuse.
378
+ // 5. Shipping: address as soon as it's known, then a method if asked for one
391
379
  cart = await inv("commerce/storefront-cart", { action: "set-shipping-address",
392
380
  cart_token: token, address: { country: "US", state: "CA", postcode: "90210", city: "Los Angeles" } });
393
381
  if (cart.shipping_status === "choice_required") {
@@ -396,24 +384,23 @@ if (cart.shipping_status === "choice_required") {
396
384
  cart_token: token, method_id: picked.id });
397
385
  }
398
386
 
399
- // 6. Place the order (offline → on-hold; no money has arrived yet)
387
+ // 6. Place it (offline → on-hold; no money has arrived yet)
400
388
  const order = await inv("commerce/storefront-checkout", { action: "place-order",
401
389
  cart_token: token, payment_method: "offline",
402
390
  billing: { first_name: "Ada", last_name: "Lovelace", address_1: "1 St",
403
391
  city: "Los Angeles", state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
404
392
 
405
- // 7. Track it later without an account (order_key from step 6)
393
+ // 7. Track it later without an account, using order_key from step 6
406
394
  const tracked = await inv("commerce/storefront-account", { action: "get-order",
407
395
  order_id: order.order_id, order_key: order.order_key });
408
396
  ```
409
397
 
410
398
  ## Walkthrough B — member experience
411
399
 
412
- ```js
413
- const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
414
- // (caller is authenticated via base44 auth — the SDK sends the session automatically)
400
+ Same, for an authenticated caller (the SDK sends the session automatically).
415
401
 
416
- // Browsing carts made while logged out merge into this one automatically on `get`/`create`.
402
+ ```js
403
+ // Carts made while logged out merge into this one automatically on `get`/`create`.
417
404
  let cart = await inv("commerce/storefront-cart", { action: "create", items: [{ product_id, quantity: 2 }] });
418
405
 
419
406
  // Saved addresses speed up checkout
@@ -425,7 +412,7 @@ const order = await inv("commerce/storefront-checkout", { action: "place-order",
425
412
  cart_token: cart.cart_token, payment_method: "offline", billing: { /* ... */ } });
426
413
  // offline → on-hold; show order.payment_instructions.account_details
427
414
 
428
- // Order history, downloads, reviews (all auth, no order_key needed)
415
+ // Order history, downloads, reviews all auth, no order_key needed
429
416
  const { orders } = await inv("commerce/storefront-account", { action: "my-orders", per_page: 10 });
430
417
  const { downloads } = await inv("commerce/storefront-account", { action: "my-downloads" });
431
418
  const file = await inv("commerce/storefront-account", { action: "get-download", permission_id: downloads[0]?.permission_id });