@base44/app-plugin-commerce 0.1.6 → 0.1.7

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 (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -1,6 +1,6 @@
1
1
  # Installation Guidelines
2
2
 
3
- How to install the Base44 Commerce Template into an existing Base44 app. This file lives inside the **commerce skill** folder and is installed into the app at `skills/commerce/installation-guidelines.md`. Once the files are in place, continue with [`post-installation.md`](./post-installation.md) (embedding the admin pages, seeding the store's data, the storefront quick start); day-2 guidance lives in [`skills/commerce/SKILL.md`](./SKILL.md), alongside the API references in [`skills/commerce/docs/`](./docs/).
3
+ How to install the Base44 Commerce Template into an existing Base44 app. This file lives inside the **commerce skill** folder and is installed into the app at `.agents/skills/commerce/installation-guidelines.md`. Once the files are in place, continue with [`post-installation.md`](./post-installation.md) (embedding the admin pages, seeding the store's data, the storefront quick start); day-2 guidance lives in [`.agents/skills/commerce/SKILL.md`](./SKILL.md), alongside the API references in [`.agents/skills/commerce/docs/`](./docs/).
4
4
 
5
5
  > **If you are a Base44 agent working inside the runtime, read this first:**
6
6
  > - **Skip the `npx base44` commands.** The runtime deploys functions and pushes entities automatically the moment you write the files — writing a resource file *is* the deploy.
@@ -27,21 +27,21 @@ Relative to the script's own folder (`examples/commerce/scripts/`), it copies:
27
27
  | `../base44/agents/commerce/` | `../../../base44/agents/commerce/` |
28
28
  | `../src/commerce/admin/` | `../../../src/commerce/admin/` |
29
29
  | `../src/commerce/utils/` | `../../../src/commerce/utils/` |
30
- | `../skills/commerce/` | `../../../skills/commerce/` |
30
+ | `../skills/commerce/` | `../../../.agents/skills/commerce/` |
31
31
 
32
- Directories are merged: files owned by the template are overwritten (re-running after a template update is safe); everything else in your app is left untouched. Files the template has since **renamed or retired** are deleted on install (it reports each one) — otherwise stale guidance would sit in `skills/` forever, and agents read whatever is there. The skill folder carries all the documentation — `SKILL.md`, this file, `post-installation.md`, the topic references in `references/` and the API docs in `docs/` — so the installed app gets it at `skills/commerce/` where agents pick it up natively; the template repo itself also stays under `examples/commerce/` for reference.
32
+ Directories are merged: files owned by the template are overwritten (re-running after a template update is safe); everything else in your app is left untouched. Files the template has since **renamed or retired** are deleted on install (it reports each one) — otherwise stale guidance would sit in `.agents/skills/` forever, and agents read whatever is there. The skill folder carries all the documentation — `SKILL.md`, this file, `post-installation.md`, the topic references in `references/` and the API docs in `docs/` — so the installed app gets it at `.agents/skills/commerce/` where agents pick it up natively; the template repo itself also stays under `examples/commerce/` for reference.
33
33
 
34
34
  **Manual.** Equivalently, copy by hand:
35
35
 
36
36
  1. Copy `base44/entities/commerce.*`, `base44/functions/commerce/*`, `base44/shared/commerce/*` and `base44/agents/commerce/*` into your app's `base44/` dir (merge, don't overwrite unrelated files). `shared/` is bundled into every function at deploy time.
37
37
  2. Copy `src/commerce/admin/` → `src/commerce/admin/` and `src/commerce/utils/` → `src/commerce/utils/` (the storefront variant-selection helpers; framework-free, no deps).
38
- 3. Copy `skills/commerce/` → `skills/commerce/` (the commerce skill — `SKILL.md`, this file, `post-installation.md`, the `references/` topic guides and the `docs/` API references — for agents working on the app).
38
+ 3. Copy `skills/commerce/` → `.agents/skills/commerce/` (the commerce skill — `SKILL.md`, this file, `post-installation.md`, the `references/` topic guides and the `docs/` API references — for agents working on the app).
39
39
 
40
40
  Confirm your `base44/config.jsonc` `entitiesDir`/`functionsDir` point at these folders (the defaults do).
41
41
 
42
42
  ## 2. Deploy and wire up
43
43
 
44
- 1. `npx base44 entities push` — creates/updates the 24 entity schemas. *(CLI path only — the Base44 runtime deploys on write.)*
44
+ 1. `npx base44 entities push` — creates/updates the 20 entity schemas. *(CLI path only — the Base44 runtime deploys on write.)*
45
45
  2. `npx base44 functions deploy` — deploys the 16 functions. *(CLI path only.)*
46
46
  3. `npx base44 agents push` — registers the `commerce/StoreAdmin` agent (§3). *(CLI path only.)*
47
47
  4. Check the app's `package.json` for `sonner`, `recharts` and `react-markdown`, and run `npm i` **only** for the ones actually absent — all three ship with the default Base44 template, so the normal outcome is no install at all. Do not re-install a package that is already a dependency. Nothing else is needed. Verify the shadcn primitives listed in [`src/commerce/admin/README.md`](../../src/commerce/admin/README.md) exist in your app.
@@ -54,7 +54,7 @@ Check the install at any time:
54
54
  ```js
55
55
  const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
56
56
  // → { template_version, seeded, settings_groups, counts: { "commerce.Product": n, ... },
57
- // checks: { has_payment_gateways, has_default_zone } }
57
+ // checks: { has_payment_gateways, has_default_location } }
58
58
  ```
59
59
 
60
60
  `commerce/seed-store` is **idempotent** and starts with a **canary schema check**: it probe-writes one record per entity it will touch and deletes it. If you've modified an entity schema incompatibly, it aborts with HTTP 422 `schema_incompatible` and writes nothing:
@@ -77,7 +77,7 @@ The template ships an AI copilot for store operators:
77
77
  - **No `model` field, on purpose.** The platform's default-model path accepts slash-namespaced tool names; explicitly setting a `model` currently rejects them (LLM tool names must match `^[a-zA-Z0-9_-]{1,128}$`). If you set a model, the bot fails at message time with a `tools.0.custom.name` error.
78
78
  - **Variant safety** — the agent is instructed to never auto-pick a variation: for a product carrying attributes it fetches `{product, variations}` via `commerce/storefront-catalog get-product`, presents the variants as a table, and asks the operator which `variation_id` to use before touching an order, stock, or download grant.
79
79
  - **Bot UI** — `src/commerce/admin/bot/` (chat panel; "StoreAdmin bot" launcher at the bottom of the admin sidebar). Responses render as markdown via `react-markdown`, with the agent's GFM tables (`| col |` with `|---|` separators) rendered by the template's own `bot/pipe-tables.js` — no markdown plugin dependency. The panel lives behind the same `AuthGuard` as the rest of the admin.
80
- - **Config it can't change, it links to.** Store settings, tax rates, shipping zones, gateways and webhook definitions have no function tool, so the agent is instructed to name the screen and emit an `admin:`-scheme link (`[Settings → Tax](admin:settings/tax)`) rather than telling the operator to "do it manually". `bot/Markdown.jsx` resolves `admin:` through `useAdminHref()`, so links follow your actual mount point (`basePath`) and navigate in-app instead of opening a tab. If you add screens, add the path to the table in the agent's instructions.
80
+ - **Config it can't change, it links to.** Store settings, Shipping & Tax Locations, gateways and webhook definitions have no function tool, so the agent is instructed to name the screen and emit an `admin:`-scheme link (`[Settings → Shipping & Tax](admin:settings/shipping-tax)`) rather than telling the operator to "do it manually". `bot/Markdown.jsx` resolves `admin:` through `useAdminHref()`, so links follow your actual mount point (`basePath`) and navigate in-app instead of opening a tab. If you add screens, add the path to the table in the agent's instructions.
81
81
 
82
82
  **Do not weaken the tool set.** The agent's power comes only from the admin functions' own `requireAdmin()` layer — don't add entity tools or service-role calls to the agent config, and keep `commerce/storefront-*` tools limited to the read-only catalog.
83
83
 
@@ -89,4 +89,4 @@ If your work includes a customer-facing shopfront, read **[`SKILL.md` → *If yo
89
89
 
90
90
  ## 5. Next steps
91
91
 
92
- Continue with [`post-installation.md`](./post-installation.md): embedding the admin pages (router mount, admin-role enforcement), seeding the store's data, and the logic-only storefront quick start. After that, [`skills/commerce/SKILL.md`](./SKILL.md) is the map for all day-2 work.
92
+ Continue with [`post-installation.md`](./post-installation.md): embedding the admin pages (router mount, admin-role enforcement), seeding the store's data, and the logic-only storefront quick start. After that, [`.agents/skills/commerce/SKILL.md`](./SKILL.md) is the map for all day-2 work.
@@ -1,6 +1,6 @@
1
1
  # Post-installation
2
2
 
3
- What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `skills/commerce/post-installation.md`.
3
+ What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `.agents/skills/commerce/post-installation.md`.
4
4
 
5
5
  ---
6
6
 
@@ -44,11 +44,11 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
44
44
 
45
45
  ## 2. Store data — seeding
46
46
 
47
- A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices), the `offline` and `stripe` payment gateways, three tax classes and a free "Rest of the world" fallback shipping zone — and, depending on the payload, the catalog. Pass **`currency`** (an ISO code, e.g. `"EUR"`) to set the store currency instead of the USD default — an explicit currency always wins, on a first seed and a re-run alike, and brings the currency's standard decimal count with it.
47
+ A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
48
48
 
49
49
  | Mode | Body | Products created |
50
50
  |---|---|---|
51
- | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `tax_rates`) | Yours — categories, attributes, variants and all, in this one call |
51
+ | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | Yours — categories, ribbons, attributes, variants and all, in this one call |
52
52
  | **Demo data** | `{ store_name, with_sample_data: true }` | The template's 10 generic demo products (skipped if any product exists) |
53
53
  | **No products** | `{ store_name }` | None — defaults only |
54
54
 
@@ -60,7 +60,9 @@ The call runs a canary schema check first — on **422** `schema_incompatible` f
60
60
 
61
61
  ### 2.1 The `products` payload
62
62
 
63
- Reference everything by **display name** — categories, tags, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
63
+ **This is the store's real data, not template data.** The call writes the actual `commerce.*` records the admin and the storefront API read from that moment on — nothing is post-processed, substituted or "filled in by the platform" later. Whatever you pass is exactly what `list-products`/`get-product` return, so pass final values (real copy, real prices, permanently-resolvable image URLs) and build the storefront on what the API returns — never on client-side constants mirroring the seed (e.g. a slug→image map): the database is the single source of truth, and such a mirror silently diverges the first time a product is edited in the admin.
64
+
65
+ Reference everything by **display name** — categories, ribbons, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
64
66
 
65
67
  ```js
66
68
  await base44.functions.invoke("commerce/seed-store", {
@@ -73,7 +75,7 @@ await base44.functions.invoke("commerce/seed-store", {
73
75
  regular_price: 19.99,
74
76
  stock_quantity: 50, // implies manage_stock: true
75
77
  categories: ["Clothing"], // get-or-create by name
76
- tags: ["bestseller"],
78
+ ribbons: ["Best Seller"],
77
79
  images: ["https://…/tee.jpg"], // URLs or { src, alt }
78
80
  short_description: "A soft, breathable everyday tee.",
79
81
  description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
@@ -99,10 +101,16 @@ await base44.functions.invoke("commerce/seed-store", {
99
101
  },
100
102
  ],
101
103
  coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
104
+ locations: [{ // optional — passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
105
+ name: "Israel", countries: ["IL"],
106
+ shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
107
+ tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
108
+ shipping_tax: { type: "percent", value: 18 }, // or { type: "fixed", value: 5 }
109
+ }],
102
110
  });
103
111
  ```
104
112
 
105
- What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/tag counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
113
+ What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/ribbon counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
106
114
 
107
115
  **Re-runs converge instead of duplicating**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported — safe for retries after a timeout, and for seeding into a store that already has products. Limits: ≤100 products and ≤500 variations per call, ≤50 variations per product (an auto-generated cartesian above that is rejected — pass explicit `variations`).
108
116
 
@@ -111,9 +119,9 @@ The response reports everything:
111
119
  ```jsonc
112
120
  { "seeded": { "settings_groups": 6, "gateways": 2, ... },
113
121
  "catalog": {
114
- "categories": { "created": 2, "reused": 0 }, "tags": { ... }, "attributes": { ... }, "terms": { ... },
122
+ "categories": { "created": 2, "reused": 0 }, "ribbons": { ... }, "attributes": { ... }, "terms": { ... },
115
123
  "products_created": 2, "products_skipped": 0, "variations_created": 4,
116
- "coupons": { "created": 1, "skipped": 0 }, "tax_rates": { "created": 0, "skipped": 0 },
124
+ "coupons": { "created": 1, "skipped": 0 }, "locations": { "created": 1, "skipped": 0 },
117
125
  "products": [
118
126
  { "name": "Classic T-Shirt", "id": "…", "slug": "classic-t-shirt", "sku": "TEE-CLASSIC", "variation_count": 0 },
119
127
  { "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "sku": "SNK-RUN", "variation_count": 4 }
@@ -123,13 +131,13 @@ The response reports everything:
123
131
  "currency": { "value": "EUR", "action": "created" } } // "updated" | "unchanged" on re-runs; null when not passed
124
132
  ```
125
133
 
126
- **Images**: every product needs at least one. Use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product.
134
+ **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
127
135
 
128
136
  A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
129
137
 
130
138
  ### 2.2 Payments
131
139
 
132
- Card payments are **already implemented** (hosted payment page, payment links, refunds). **Get the payment provider connected at the beginning of the implementation** — have the platform's **Stripe** integration configured for the app while you build, so it is already connected by the time you create the checkout and a test order can prove the whole path. The `offline` gateway (manual reconciliation) works with nothing to configure. Provider internals, webhooks and refunds are day-2 material: [`references/online-payments.md`](./references/online-payments.md).
140
+ The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions. The **Credit card** option needs a payment provider, which means implementing exactly two files (`shared/commerce/card-payment.ts` + the payment webhook); until then it answers `503 no_card_payment_provider` at checkout, or can simply be switched off. Details: [`references/online-payments.md`](./references/online-payments.md).
133
141
 
134
142
  ---
135
143
 
@@ -149,25 +157,26 @@ The cart is identified by a **`cart_token`** the backend mints — persist it in
149
157
 
150
158
  ```js
151
159
  const info = await inv("commerce/storefront-catalog", { action: "get-store-info" });
152
- // info.settings → { store_name, currency, currency_position, num_decimals, … } — format money with these
160
+ // info.settings → { store_name, currency, weight_unit, … } — format money with
161
+ // Intl.NumberFormat(undefined, { style: "currency", currency: info.settings.currency })
153
162
  // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
154
163
  // info.countries / info.currencies → static tables for address forms and money display
155
164
 
156
165
  const { products, page, per_page, has_next } = await inv("commerce/storefront-catalog", {
157
166
  action: "list-products",
158
- page: 1, per_page: 12, // optional: search, category_id, tag_id, featured, on_sale,
167
+ page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
159
168
  sort: "-created_date", // min_price, max_price, in_stock_only
160
169
  }); // sort: -created_date | name | price | -price | popularity | rating
161
170
  ```
162
171
 
163
- Each row is a full product record — for a card use `name`, `images[0]?.src`, `price`, `regular_price`, `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `tags` (`[{ id, name }]`, may be absent). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `{ action: "list-categories" }` (a tree via `parent_id`).
172
+ Each row is a full product record — for a card use `name`, `images[0]?.src`, `price`, `regular_price`, `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `{ action: "list-categories" }` (a tree via `parent_id`).
164
173
 
165
174
  **Carry forward:** each card links to the product page by **`slug`**.
166
175
 
167
176
  ### 3.2 Product page — variant selection included
168
177
 
169
178
  ```js
170
- const { product, variations, categories, tags, reviews } =
179
+ const { product, variations, categories, ribbons, reviews } =
171
180
  await inv("commerce/storefront-catalog", { action: "get-product", slug }); // or { id }
172
181
 
173
182
  // One selector PER product.attributes[] entry — never a flat list of variations.
@@ -184,21 +193,21 @@ const view = resolveSelection(product, variations, selection);
184
193
  // view.addToCart → { product_id, variation_id } — null until the selection resolves
185
194
  ```
186
195
 
187
- Add to cart — **create** mints the `cart_token` on first add; every later call reuses it:
196
+ Add to cart — `add-item` bootstraps the cart itself: a missing, stale or expired token starts a fresh cart, so the one rule is to **re-persist the `cart_token` from every response** (your cached token may have expired — 48h TTL — or been consumed by a checkout):
188
197
 
189
198
  ```js
190
- let cart_token = localStorage.getItem("cart_token");
191
- if (!cart_token) {
192
- const cart = await inv("commerce/storefront-cart", { action: "create" });
193
- cart_token = cart.cart_token;
194
- localStorage.setItem("cart_token", cart_token);
195
- }
196
- await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity: 1 });
199
+ const cart = await inv("commerce/storefront-cart", {
200
+ action: "add-item",
201
+ cart_token: localStorage.getItem("cart_token") || undefined, // fine if absent/stale
202
+ ...view.addToCart,
203
+ quantity: 1,
204
+ });
205
+ localStorage.setItem("cart_token", cart.cart_token); // ALWAYS — the token may be a new cart's
197
206
  ```
198
207
 
199
- A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call. Show `reviews` (`{ items, has_next, average_rating, rating_count }`) and the `upsells`/`cross_sells` summaries the same response carries.
208
+ A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call. Show `reviews` (`{ items, has_next, average_rating, rating_count }`) and the `upsells`/`cross_sells` summaries the same response carries. On the other cart actions a `404 cart_not_found|cart_expired` means the cached token went stale — clear it and treat the cart as empty.
200
209
 
201
- **Carry forward:** the **`cart_token`**.
210
+ **Carry forward:** the **`cart_token`** — from the response, every time.
202
211
 
203
212
  ### 3.3 Cart
204
213
 
@@ -215,42 +224,51 @@ cart = await inv("commerce/storefront-cart", { action: "remove-item", cart_token
215
224
  cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token, code });
216
225
  ```
217
226
 
218
- Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`):
227
+ Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`). **Call `set-shipping-address` the moment the customer provides an address** — every shipping option and cost is recalculated by that call (never reuse a list fetched earlier), and an address the store doesn't ship to **fails right there** with `400 shipping_not_available`, so the address form is where you surface it:
219
228
 
220
229
  ```js
230
+ // as soon as the address is entered — this is what (re)calculates shipping options + cost
221
231
  cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token,
222
- address: { country, state, postcode, city } });
232
+ address: { country, state, postcode, city } }); // 400 shipping_not_available → show it on the address form
233
+ // cart.chosen_shipping_method is the rate's ID (a string) — to display it, look
234
+ // it up: cart.available_shipping_methods.find(m => m.id === cart.chosen_shipping_method)
235
+ // and render that entry's title + cost. Never render the id itself.
223
236
  switch (cart.shipping_status) {
224
237
  case "auto_selected": break; // only ONE option existed — the backend already applied it;
225
- // no picker needed, just display cart.chosen_shipping_method
238
+ // no picker needed, just display the looked-up title + cost
226
239
  case "chosen": break; // customer's earlier choice still valid
227
240
  case "choice_required": // several options — MUST render cart.available_shipping_methods
228
241
  // [{ id, title, cost }] as a picker, then send the customer's pick:
229
242
  cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token,
230
243
  method_id: picked.id }); // the entry's id, not its method_id type
231
244
  break;
232
- case "none_available": /* the store doesn't ship there — say so, don't place the order */ break;
245
+ case "missing_address": break; // several zones, no address yet — shipping cost is NOT
246
+ // calculated; collect the address and call set-shipping-address
233
247
  case "not_needed": break; // fully virtual cart
234
248
  }
235
249
  ```
236
250
 
251
+ A store with exactly **one shipping zone** shows its options (and, auto-selected, the cost) on the cart even before an address is set — the options are the same everywhere. Several zones report `missing_address` until the address resolves one.
252
+
237
253
  **Carry forward:** the **`cart_token`** (address and method choice live on the cart).
238
254
 
239
255
  ### 3.4 Checkout & order-received
240
256
 
241
- The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods, which come from `info.payment_gateways` (step 1) — already filtered to what can actually take payment right now, so every entry you render is payable:
257
+ The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods, which come from `info.payment_gateways` (step 1) — every gateway the admin has **enabled**:
242
258
 
243
259
  ```js
244
- // card payments ride the app's Stripe integration — setup & provider details:
245
- // skills/commerce/references/online-payments.md
260
+ // wiring a card provider = implementing two files — details:
261
+ // .agents/skills/commerce/references/online-payments.md
246
262
  const gateways = info.payment_gateways; // [{ slug, title, description, online }] — admin-owned data
247
263
  // several → render a picker using the admin's title/description as the labels
248
264
  // exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
249
265
  // none → checkout cannot complete — say so instead of rendering a dead button
250
- const payment_method = gateways.length === 1 ? gateways[0].slug : picked.slug; // never a hardcoded "stripe"
266
+ const payment_method = gateways.length === 1 ? gateways[0].slug : picked.slug; // never a hardcoded "card"
251
267
  ```
252
268
 
253
- `online: true` marks the card/redirect gateway; `offline` is manual reconciliation. Then one call places the order:
269
+ While the store has no card provider implemented, picking the card gateway fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods.
270
+
271
+ `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation. Then one call places the order:
254
272
 
255
273
  ```js
256
274
  const res = await inv("commerce/storefront-checkout", {
@@ -260,11 +278,11 @@ const res = await inv("commerce/storefront-checkout", {
260
278
  // shipping: { … } if it differs from billing; customer_note?; return_url: window.location.origin
261
279
  });
262
280
  // res → { order_id, order_number, order_key, status, totals, order,
263
- // payment_instructions, // offline: { description, account_details } — render them
264
- // payment } // online: { status: "requires_payment", checkout_url, … } | null
281
+ // payment_instructions, // manual gateways: { description, account_details } — render them
282
+ // payment } // card: { status: "requires_payment", checkout_url, … } | null
265
283
 
266
284
  if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
267
- else showConfirmation(res); // offline order placed — show payment_instructions
285
+ else showConfirmation(res); // manual order placed — show payment_instructions
268
286
  ```
269
287
 
270
288
  Every payment link returns to **`/order-received`** — the page from §1 step 3. It is one call, idempotent, safe on every visit:
@@ -272,21 +290,24 @@ Every payment link returns to **`/order-received`** — the page from §1 step 3
272
290
  ```js
273
291
  // GET /order-received?order_id=…&order_key=…&payment=success|cancel
274
292
  const params = new URLSearchParams(window.location.search);
275
- const { state, order, payment_link } = await inv("commerce/payments", {
293
+ const { state, order, payment_link, payment_instructions } = await inv("commerce/payments", {
276
294
  action: "complete-return",
277
295
  order_id: params.get("order_id"), order_key: params.get("order_key"),
278
296
  payment: params.get("payment"), // only a hint — the server verifies with the provider
279
297
  return_url: window.location.origin,
280
298
  });
281
299
  // state === "paid" → thank-you + order summary (order is now marked paid)
282
- // state === "unpaid" → offer payment_link.url to try again
300
+ // state === "unpaid" → card order: offer payment_link.url to pay now;
301
+ // manual order: render payment_instructions ({ description, account_details })
283
302
  // state === "cancelled" → payment was cancelled — offer payment_link.url or support
284
303
  ```
285
304
 
305
+ Two shapes to get right when rendering: **`order` carries flat totals** — `order.total`, `order.shipping_total`, `order.total_tax` — there is **no `order.totals` object** (that nested shape belongs to the cart view and the place-order response's top-level `totals`); and `payment_link` is `{ url, reference } | null` (card orders only).
306
+
286
307
  **Carry forward:** `order_id` + `order_key` are the guest's proof of ownership — `commerce/storefront-account` `get-order` with both returns the order for a tracking page (signed-in customers get `my-orders` with no key).
287
308
 
288
309
  ---
289
310
 
290
311
  ## 4. Next
291
312
 
292
- Continue with the commerce skill — [`skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), Stripe wiring, scheduled maintenance, emails, webhooks, and operational limits.
313
+ Continue with the commerce skill — [`.agents/skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), payment provider wiring, scheduled maintenance, emails, webhooks, and operational limits.
@@ -4,20 +4,23 @@ Where to change what, in `src/commerce/admin/pages/products/`. The shape follows
4
4
  rule: **a product sells variants because it carries attributes** — there is no product
5
5
  type — so everything a customer actually buys is edited in one place.
6
6
 
7
- ## Tabs
7
+ ## Sections
8
8
 
9
- `components/ProductDataPanel.jsx` builds the list. There are four, and two are conditional:
9
+ `components/ProductDataPanel.jsx` stacks the sections one below the other — no side menu.
10
+ There are four, and one is conditional:
10
11
 
11
- | Tab | File | Holds |
12
+ | Section | File | Holds |
12
13
  |---|---|---|
13
- | **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/class and *limit to 1 per order* (product-wide) → the **Attributes** section → a row per purchasable thing |
14
+ | **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/group and *limit to 1 per order* (product-wide) → the **Attributes** section → a row per purchasable thing |
14
15
  | **Modifiers** | `components/tabs/ModifiersTab.jsx` | `meta_data` key/value pairs — descriptive properties (Material, Care, GTIN). They never affect price, stock or variants |
15
16
  | **Downloads** | `components/tabs/DownloadsTab.jsx` | Only when `downloadable` |
16
17
  | **Linked products** | `components/tabs/LinkedTab.jsx` | Upsells and cross-sells |
17
18
 
18
- `Virtual` and `Downloadable` are checkboxes in the panel header, not tabs. There is no
19
- General, Inventory, Variations, Attributes, Shipping, Advanced or External tab — all of
20
- those were removed, and `scripts/install.js` retires the files on upgrade.
19
+ `Virtual` and `Downloadable` are checkboxes in the panel header. The main column reads
20
+ Name → Description → Short description → Product data; the sidebar's **Publish** box has a
21
+ single **Visible** toggle driving `status` (publish/draft) — there is no catalog-visibility
22
+ field. The product's **Tax group** names a group defined per Shipping & Tax Location
23
+ (default `Products`).
21
24
 
22
25
  ## Price & Inventory
23
26
 
@@ -25,9 +28,9 @@ those were removed, and `scripts/install.js` retires the files on upgrade.
25
28
  edits the product's own fields. A product with attributes shows a row per variant.
26
29
  Both go through the same `PriceFields` / inventory / `ShippingFields` / `DownloadsFields`
27
30
  components, so there is one definition to change, not two.
28
- - **Shipping is per row.** Weight, dimensions and shipping class live on each variant (a
29
- large mug weighs more than a small one); the Base price row edits the product's own.
30
- Hidden when the product is virtual.
31
+ - **Shipping is per row.** Weight and dimensions live on each variant (a large mug weighs
32
+ more than a small one); the Base price row edits the product's own. Hidden when the
33
+ product is virtual.
31
34
  - **The parent price is not editable** when variants exist — `commerce/admin-products`
32
35
  derives `regular_price`, `price` and `on_sale` from the cheapest publishable variant on
33
36
  save, and the seeder does the same. Don't add an input for it.
@@ -52,5 +55,5 @@ merchant touches anything. Keep that guard if you touch the effect.
52
55
 
53
56
  Attribute and value *records* are shared between products, so the **Manage attributes**
54
57
  dialog saves them immediately, unlike the rest of the form which batches until Save. There
55
- are no Attributes or Tags pages in the side menu: attributes are reachable only from here,
56
- tags from the `TaxonomyPanel` card in the product sidebar.
58
+ are no Attributes or Ribbons pages in the side menu: attributes are reachable only from
59
+ here, ribbons from the `TaxonomyPanel` card in the product sidebar.
@@ -1,10 +1,10 @@
1
1
  # Emails
2
2
 
3
- Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`. Ten order emails are wired to the lifecycle (see the side-effect matrix in [`skills/commerce/docs/api-admin.md`](../docs/api-admin.md)): `new_order`, `cancelled_order`, `failed_order`, `on_hold_order`, `processing_order`, `completed_order`, `refunded_order`, `partial_refund`, `customer_invoice`, `customer_note`. `reset_password` and `new_account` are handled by **Base44 auth**, not this template.
3
+ Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`. Ten order emails are wired to the lifecycle (see the side-effect matrix in [`.agents/skills/commerce/docs/api-admin.md`](../docs/api-admin.md)): `new_order`, `cancelled_order`, `failed_order`, `on_hold_order`, `processing_order`, `completed_order`, `refunded_order`, `partial_refund`, `customer_invoice`, `customer_note`. `reset_password` and `new_account` are handled by **Base44 auth**, not this template.
4
4
 
5
5
  - Per-type enable/subject/heading/recipient/additional_content overrides live at the **top level** of the `emails` StoreSettings group, keyed by type id (`emails.new_order.enabled`) — that is the path `shared/commerce/emails.ts` reads. Editable in Settings → Emails; blank = built-in default. Don't nest them under a sub-object: the backend won't see them.
6
6
  - Admin notifications (`new_order`, `cancelled_order`, `failed_order`) resolve in three steps: the per-type `recipient` if it holds an address, else `emails.admin_recipients`, else — at send time — **the app's users with `role: "admin"`** (`sr.entities.User.filter({role:"admin"})`, memoized per isolate). Both settings accept a comma-separated list and drop blank entries, so a cleared per-type override falls back rather than sending to an empty address. Nothing is seeded: a fresh store notifies its admin users until someone sets explicit recipients, and promoting an admin is enough to add them. Only when there is also no admin user does the email go nowhere, logged in `commerce.EmailLog` with `success: false` and an `error` naming what was empty. Settings → Emails shows the addresses actually in effect (via `commerce/admin-tools` `admin-email-recipients`).
7
- - Stock notifications follow the same chain: `inventory.notification_recipient`, else the first `emails.admin_recipients` entry, else the first admin user.
7
+ - Stock notifications (`low_stock`, `out_of_stock`) sit in the same Settings → Emails list and are configured per type like the rest (`emails.low_stock.enabled`, `emails.low_stock.recipient`). Recipients resolve exactly like the admin order emails: per-type `recipient`, else `emails.admin_recipients`, else the app's admin users — comma-separated lists reach every address. Subject and body are generated (product name, SKU, remaining stock), so there are no subject/heading overrides for these two.
8
8
  - The **store name** is `emails.store_name` — one setting, used for both the `{store_name}` in subjects/headings and the sender name. It lives nowhere else, but it is not email-only: `storefrontSafeSettings()` publishes it as `settings.store_name` on `get-store-info`, so it is also the public shop name. `commerce/seed-store` fills it from the `store_name` it is passed, and Settings → Emails is where a merchant changes it.
9
9
  - It seeds **blank on purpose**: a blank name means `SendEmail` is called without `from_name`, in which case **Base44 sends as the app's name** (verified: an app named *Canvas* with it blank delivered mail from "Canvas"). So the sender is the app name unless the store overrides it — one place to rename, and no hardcoded default.
10
10
  - That fallback does **not** reach subjects or headings: those are rendered by this template, which has no access to the app name, so a store with no `emails.store_name` sends `New order #1002` rather than `[Canvas]: New order #1002`. Set the store name if you want it in the subject line.
@@ -12,7 +12,7 @@
12
12
  The storefront functions run as the service role, so RLS is not protecting the caller from itself — these guards are. Keep them when you extend a function, and follow them in a new one.
13
13
 
14
14
  - **Identity comes from the session, never from the body.** An email in the payload is a claim, not a credential. Use `getCallerUser(base44)` and `requireUser(user)` from `shared/commerce/auth.ts`, and `ownsEmail(user, email)` before writing to anything keyed on someone's address. This is why `submit-review` requires a signed-in customer (a `reviewer_email` field is ignored) and why a guest checkout attaches to an existing `commerce.Customer` without rewriting its saved name and addresses — otherwise knowing a customer's email would be enough to redirect where their next order ships, or to post a review in their name.
15
- - **Anything a person owns needs authentication, not just an email.** If you add reviews, wishlists, loyalty, saved payment details, subscriptions or support tickets, gate the write on `requireUser` and derive the owner from `user.email` / `user.id`. Guest access is only ever by bearer token (`cart_token`, `order_key`) for the *one* record that token names.
16
- - **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmOnlinePayment()`, which asks the provider and checks the session was opened for that order. Adding a gateway means writing an adapter (see [`online-payments.md`](./online-payments.md)), not a new "confirm" endpoint.
15
+ - **Anything a person owns needs authentication, not just an email.** If you add wishlists, loyalty, saved payment details, subscriptions or support tickets, gate the write on `requireUser` and derive the owner from `user.email` / `user.id`. Guest access is only ever by bearer token (`cart_token`, `order_key`) for the *one* record that token names. (Reviews are the deliberate exception — public by email, moderated instead of authenticated; a session email still always beats the payload.)
16
+ - **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmCardPayment()`, which asks the provider about the payment reference stored on the order. Wiring a provider means implementing the two payment files (see [`online-payments.md`](./online-payments.md)), never a new "confirm" endpoint.
17
17
  - **A bearer token authorizes one record.** `order_key` gets you *that* order; it is not a licence to name someone else's ids in the same request. Match every id in the payload back to the record the token opened.
18
18
  - **Don't return more than the caller asked about.** Serialize customer-facing orders through `serializeOrderForCustomer()`, and don't let a response reveal whether another person's email exists, bought something, or has an account — a boolean in an error body is an enumeration oracle.