@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
@@ -14,10 +14,9 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
14
14
  | commerce.Coupon | direct | **`commerce/admin-coupons`** | code normalization/uniqueness, webhooks |
15
15
  | commerce.Customer | direct | **`commerce/admin-customers`** | email uniqueness, invite/link, stats |
16
16
  | commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
17
- | commerce.ProductCategory, commerce.ProductTag | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; tag get-or-create by name |
17
+ | commerce.ProductCategory, commerce.ProductRibbon | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; ribbon get-or-create by name |
18
18
  | commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
19
- | commerce.ShippingClass | direct | **direct CRUD** | plain config; RLS enforces admin-only |
20
- | commerce.TaxClass, commerce.TaxRate, commerce.ShippingZone, commerce.ShippingZoneMethod, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
19
+ | commerce.ShippingTaxLocation, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
21
20
  | commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
22
21
  | commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
23
22
  | commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
@@ -45,26 +44,26 @@ Success: `{ success: true, data }`. Failure: `{ success: false, error, code }` w
45
44
  Actions: `save` · `delete` · `batch` · `duplicate` · `set-stock` · `search` · `save-term` · `delete-term` · `list-terms`
46
45
 
47
46
  - **`save`** — `{ product, variations? }`. Upserts the product (create if no `id`); when `variations` is provided, diffs them (create/update/delete-missing).
48
- > **Selling variants takes both of these in *this* call**: the attribute listed **on the product** as `attributes: [{ attribute_id, name, position, options: [...] }]`, plus a `variations` array with one entry per stocked combination (each `{ attributes: [{ attribute_id, name, option }], sku, regular_price, manage_stock: "yes", stock_quantity, status: "publish" }`) and `default_attributes` for the pre-selected combination. There is **no `type` field**: carrying attributes is what makes a product sell variants, so a `commerce.ProductAttribute` record on its own changes nothing — and a product listing an attribute with no variations cannot be added to a cart at all (`400 variation_required`). Descriptive properties belong in `meta_data`, not `attributes`. **Don't set the parent's price** — `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save, which is what makes catalog cards, price sorting and price filters agree. Enforces SKU + slug uniqueness across products *and* variations (auto-suffixes slug on collision; `duplicate_sku` on SKU clash). Derives `price`/`on_sale` from the sale window and `stock_status` when stock is managed; updates category/tag `count`; rolls parent stock **and price** up when the product has attributes; fires `product.created`/`product.updated`. → `{ product, variations }`.
47
+ > **Selling variants takes both of these in *this* call**: the attribute listed **on the product** as `attributes: [{ attribute_id, name, position, options: [...] }]`, plus a `variations` array with one entry per stocked combination (each `{ attributes: [{ attribute_id, name, option }], sku, regular_price, manage_stock: "yes", stock_quantity, status: "publish" }`) and `default_attributes` for the pre-selected combination. There is **no `type` field**: carrying attributes is what makes a product sell variants, so a `commerce.ProductAttribute` record on its own changes nothing — and a product listing an attribute with no variations cannot be added to a cart at all (`400 variation_required`). Descriptive properties belong in `meta_data`, not `attributes`. **Don't set the parent's price** — `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save, which is what makes catalog cards, price sorting and price filters agree. Enforces SKU + slug uniqueness across products *and* variations (auto-suffixes slug on collision; `duplicate_sku` on SKU clash). Derives `price`/`on_sale` from the sale window and `stock_status` when stock is managed; updates category/ribbon `count`; rolls parent stock **and price** up when the product has attributes; fires `product.created`/`product.updated`. → `{ product, variations }`.
49
48
  - **`delete`** — `{ id }`. Cascades variations, decrements counts, fires `product.deleted`.
50
49
  - **`batch`** — `{ create?: [], update?: [], delete?: [] }` (≤100 total) → per-item results.
51
50
  - **`duplicate`** — `{ id }` → new draft copy (name "(Copy)", suffixed SKU, reset sales/ratings) incl. variations.
52
51
  - **`set-stock`** — `{ id, variation_id?, quantity }`. Sets quantity, re-derives status, sends low/out-of-stock admin emails on threshold crossings.
53
52
  - **`search`** — `{ q?, category_id?, stock_status?, status?, sort?, limit?, skip? }` → `{ rows, has_next }`. `category_id` includes descendant categories.
54
- - **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"tag"` | `"attribute"` | `"attribute-term"`, and `term` takes the fields for that one:
53
+ - **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"ribbon"` | `"attribute"` | `"attribute-term"`, and `term` takes the fields for that one:
55
54
 
56
55
  | taxonomy | entity | `term` fields |
57
56
  |---|---|---|
58
57
  | `category` | commerce.ProductCategory | `id?, name, slug?, description?, parent_id?, image?, menu_order?` |
59
- | `tag` | commerce.ProductTag | `id?, name` |
58
+ | `ribbon` | commerce.ProductRibbon | `id?, name` |
60
59
  | `attribute` | commerce.ProductAttribute | `id?, name, code?, order?` |
61
60
  | `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name, order?` |
62
61
 
63
- Only a category has a slug, derived from its name and made unique; its `parent_id` pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). An attribute's **`code`** is derived from the name and made unique — it is the key a storefront filter URL should carry. Creating a **tag** is get-or-create: a name that already exists case-insensitively returns the existing record instead of splitting the tag in two. Renaming an **attribute value** rewrites `attributes[].options` and `default_attributes` on every product using it, and the matching `option` on their variations — products store a value by name, so the rename would otherwise orphan them.
62
+ Only a category has a slug, derived from its name and made unique; its `parent_id` pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). An attribute's **`code`** is derived from the name and made unique — it is the key a storefront filter URL should carry. Creating a **ribbon** is get-or-create: a name that already exists case-insensitively returns the existing record instead of splitting the ribbon in two. Renaming an **attribute value** rewrites `attributes[].options` and `default_attributes` on every product using it, and the matching `option` on their variations — products store a value by name, so the rename would otherwise orphan them.
64
63
 
65
- This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`tag_ids`/`attributes[].attribute_id` reference — assigning an id is useless if the record can't be created. **A product's `commerce.ProductAttribute` and its values must exist first**, so create those, then `save` the product with `attributes[]`/`variations[]`. (At install time, `commerce/seed-store` can do all of this in one call — it takes whole products by display name and get-or-creates the taxonomy internally; see below.)
66
- - **`delete-term`** — `{ taxonomy, id, detach? }` → `{ deleted, detached, terms_deleted }`. Deleting an **attribute** always deletes its terms (a term outliving its attribute is unreachable); `detach: true` additionally strips the attribute from every product's `attributes[]`. For a category or tag, products keep the id by default (the storefront skips ids that no longer resolve); `detach: true` strips it from every product first. **For an attribute *value* `detach` is a no-op** — deleting a value leaves its name in every product's `attributes[].options` and leaves the variations that use it in place, so remove the value from the products first (or expect variants the storefront can no longer resolve).
67
- - **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`, tags by `name`; `attribute_id` filters values to one attribute. Use it to reuse an existing record instead of creating a duplicate.
64
+ This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`ribbon_ids`/`attributes[].attribute_id` reference — assigning an id is useless if the record can't be created. **A product's `commerce.ProductAttribute` and its values must exist first**, so create those, then `save` the product with `attributes[]`/`variations[]`. (At install time, `commerce/seed-store` can do all of this in one call — it takes whole products by display name and get-or-creates the taxonomy internally; see below.)
65
+ - **`delete-term`** — `{ taxonomy, id, detach? }` → `{ deleted, detached, terms_deleted }`. Deleting an **attribute** always deletes its terms (a term outliving its attribute is unreachable); `detach: true` additionally strips the attribute from every product's `attributes[]`. For a category or ribbon, products keep the id by default (the storefront skips ids that no longer resolve); `detach: true` strips it from every product first. **For an attribute *value* `detach` is a no-op** — deleting a value leaves its name in every product's `attributes[].options` and leaves the variations that use it in place, so remove the value from the products first (or expect variants the storefront can no longer resolve).
66
+ - **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`, ribbons by `name`; `attribute_id` filters values to one attribute. Use it to reuse an existing record instead of creating a duplicate.
68
67
 
69
68
  ## commerce/admin-orders
70
69
 
@@ -109,14 +108,14 @@ Applied by the transition engine; each effect is flag-guarded so a re-entered st
109
108
 
110
109
  Actions: `create` · `delete`
111
110
 
112
- - **`create`** — `{ order_id, amount, reason?, line_items?, restock_items?, refund_payment? }`. `line_items` specs: `{ line_id, quantity, refund_total, refund_tax? }`. Validates `amount` ≤ remaining refundable (`400 amount_exceeds_refundable`, `400 invalid_amount`). Restocks per line when `restock_items`. Updates `order.total_refunded`; a full refund transitions the order to `refunded`, otherwise sends `partial_refund` + `order.updated`. With **`refund_payment: true`** the money goes back through the payment provider **first** — a failed provider refund writes nothing locally, so there is never a phantom refund — and the response carries `gateway_refund: { provider, id, status, amount }` with the record's `refunded_payment: true`. An order that wasn't paid online rejects it with `400 no_online_payment`. → `{ refund, order, gateway_refund? }`.
111
+ - **`create`** — `{ order_id, amount, reason?, line_items?, restock_items?, refund_payment? }`. `line_items` specs: `{ line_id, quantity, refund_total, refund_tax? }`. Validates `amount` ≤ remaining refundable (`400 amount_exceeds_refundable`, `400 invalid_amount`). Restocks per line when `restock_items`. Updates `order.total_refunded`; a full refund transitions the order to `refunded`, otherwise sends `partial_refund` + `order.updated`. With **`refund_payment: true`** the money goes back through the payment provider **first** — a failed provider refund writes nothing locally, so there is never a phantom refund — and the response carries `gateway_refund: { refund_id }` with the record's `refunded_payment: true`. An order that wasn't paid by card rejects it with `400 no_card_payment`; while the store's `card-payment.ts` refund stub is unimplemented it answers `501 card_refund_not_implemented` (record the refund without `refund_payment` instead). → `{ refund, order, gateway_refund? }`.
113
112
  - **`delete`** — `{ refund_id }`. Reverses `total_refunded`; adds a note warning that restocked items are **not** auto-un-restocked.
114
113
 
115
114
  ## commerce/admin-coupons
116
115
 
117
116
  Actions: `save` · `delete` · `batch` · `search`
118
117
 
119
- - **`save`** — `{ coupon }`. Lowercases + enforces unique `code` (`duplicate_code`); percent amount ≤ 100. Fires `coupon.created`/`updated`.
118
+ - **`save`** — `{ coupon }`. Lowercases + enforces unique `code` (`duplicate_code`); percent amount ≤ 100. `enabled: false` disables the coupon — it can't be applied and carts holding it drop it on the next re-price (validation code `disabled`). Fires `coupon.created`/`updated`.
120
119
  - **`delete`** — `{ id }` (fires `coupon.deleted`) · **`batch`** — `{ create?, update?, delete? }` · **`search`** — `{ q?, limit?, skip? }` → `{ rows, has_next }` (matches code/description).
121
120
 
122
121
  ## commerce/admin-customers
@@ -142,7 +141,7 @@ Actions: `test` · `redeliver` (webhook definitions themselves are direct `comme
142
141
 
143
142
  ## commerce/admin-reports
144
143
 
145
- All actions scan orders on demand (counted = `date_paid` set, or status `processing`/`completed`). See the commerce skill reference `skills/commerce/references/limits-and-performance.md` for scaling.
144
+ All actions scan orders on demand (counted = `date_paid` set, or status `processing`/`completed`). See the commerce skill reference `.agents/skills/commerce/references/limits-and-performance.md` for scaling.
146
145
 
147
146
  | Action | Payload | Returns |
148
147
  |---|---|---|
@@ -155,17 +154,16 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
155
154
  | `customers-totals` | — | `{ total, guests, registered, paying }` |
156
155
  | `coupons-totals` | — | `{ total, by: { [discount_type]: count } }` |
157
156
  | `reviews-totals` | — | `{ total, by: { [status]: count } }` |
158
- | `categories-totals` / `tags-totals` | — | `{ total, terms: [{ id, name, count }] }` |
157
+ | `categories-totals` / `ribbons-totals` | — | `{ total, terms: [{ id, name, count }] }` |
159
158
  | `attributes-totals` | — | `{ total, attributes: [{ id, name, terms }] }` |
160
159
 
161
160
  ## commerce/admin-tools
162
161
 
163
- Actions: `status` · `payment-connector-status` · `admin-email-recipients` · `recount-terms` · `recount-coupon-usage` · `recalculate-customer-stats-all` · `prune-webhook-deliveries` · `clear-abandoned-carts` · `regenerate-download-permissions`
162
+ Actions: `status` · `admin-email-recipients` · `recount-terms` · `recount-coupon-usage` · `recalculate-customer-stats-all` · `prune-webhook-deliveries` · `clear-abandoned-carts` · `regenerate-download-permissions`
164
163
 
165
- - **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways, has_default_zone } }` — `counts` is keyed by the **namespaced** entity name, and `checks` is an object, not an array. — mini system-status; also the seeded/health check for install verification.
166
- - **`payment-connector-status`** — no payload → `{ provider, provider_label, gateway_slug, connected, error?, connector }`. Whether an online payment provider is usable **right now**, answered by the payment utility for whichever provider is wired — so UI derives payment readiness instead of hardcoding a "not set up" notice, and shows "No payment provider connected" rather than a brand. Connectors are service-role only, hence the round trip; any failure reports `connected: false`. A provider connected **after** the function's last deploy reads as `connected: false` until the functions are redeployed — env vars are injected at deploy time ([`references/online-payments.md`](../references/online-payments.md) §1). (`connector` repeats `provider` for callers written against the older shape.)
164
+ - **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways, has_default_location } }` — `counts` is keyed by the **namespaced** entity name, and `checks` is an object, not an array. — mini system-status; also the seeded/health check for install verification.
167
165
  - **`admin-email-recipients`** — no payload → `{ recipients: string[], source: "settings" | "admin_users", admin_users: string[] }`. Where admin notifications go **right now**: `recipients` is the configured `emails.admin_recipients`, or the app's admin users when that is empty (the runtime fallback), with `source` saying which. `admin_users` is returned either way, so Settings → Emails can show the fallback as the field's placeholder even while explicit recipients are set. A client can't resolve it itself — listing users needs service role.
168
- - **`recount-terms`** — repairs category/tag/term `count`.
166
+ - **`recount-terms`** — repairs category/ribbon/term `count`.
169
167
  - **`recount-coupon-usage`** — repairs `usage_count`/`used_by`.
170
168
  - **`recalculate-customer-stats-all`** — repairs all customers' `orders_count`/`total_spent`.
171
169
  - **`prune-webhook-deliveries`** — `{ keep_days }`.
@@ -174,19 +172,18 @@ Actions: `status` · `payment-connector-status` · `admin-email-recipients` · `
174
172
 
175
173
  ## commerce/payments
176
174
 
177
- Actions: `status` · `create-link` · `complete-return` · `verify` — the admin side of online payments (the same function serves customers, who authorize with `order_key` instead; see [`api-storefront.md`](./api-storefront.md)).
175
+ Actions: `create-link` · `complete-return` · `verify` — the admin side of online payments (the same function serves customers, who authorize with `order_key` instead; see [`api-storefront.md`](./api-storefront.md)).
178
176
 
179
- - **`status`** (admin) — `{ provider, provider_label, gateway_slug, connected, error? }`.
180
- - **`create-link`** — `{ order_id }` → `{ provider, session_id, url, expires_at? }`: a provider-hosted payment page for an unpaid order, i.e. the **payment link** to send a customer. Accepts an order with **any** payment method (including none, as admin-created orders start) and switches it onto the online gateway, logging the change — refunds key off `payment_method`, so this keeps the order honest about how it was paid. `409 already_paid`, `400 online_payments_disabled`, `503 payment_provider_unavailable`.
177
+ - **`create-link`** — `{ order_id }` → `{ url, reference }`: a provider-hosted payment page for an unpaid order, i.e. the **payment link** to send a customer. Accepts an order with **any** payment method (including none, as admin-created orders start) and switches it onto the card gateway, logging the change — refunds key off `payment_method`, so this keeps the order honest about how it was paid. `409 already_paid`, `400 card_payments_disabled`, `503 no_card_payment_provider`.
181
178
  - **`verify`** — `{ order_id }` → `{ paid, already_confirmed, status, order }`: re-asks the provider and moves the order to `processing` when the money is there. Idempotent.
182
- - **`complete-return`** — `{ order_id, order_key, payment?, return_url? }` → `{ state, paid, already_confirmed, status, order, payment_link }`: what the storefront's **mandatory** `/order-received` page calls — confirm, progress the order, and hand back a render-ready result plus a fresh payment link while unpaid.
179
+ - **`complete-return`** — `{ order_id, order_key, payment?, return_url? }` → `{ state, paid, already_confirmed, status, order, payment_link, payment_instructions }`: what the storefront's **mandatory** `/order-received` page calls — confirm, progress the order, and hand back a render-ready result plus a fresh payment link while unpaid (card) or the gateway's payment instructions (manual).
183
180
 
184
- `commerce/payment-webhook` is the provider's signed callback (raw body, no `action` envelope) and needs `PAYMENT_WEBHOOK_SECRET`; it refuses every request until that is set. Provider specifics live in `base44/shared/commerce/payments.ts` — see [`../references/online-payments.md`](../references/online-payments.md).
181
+ `commerce/payment-webhook` is the provider's server-to-server callback (raw body, no `action` envelope). Its `parseWebhook` is one of the two files to implement when wiring a provider; until then it answers `400 webhook_not_implemented`. See [`../references/online-payments.md`](../references/online-payments.md).
185
182
 
186
183
  ## commerce/seed-store
187
184
 
188
- Not action-routed. Body `{ store_name?, currency?, with_sample_data?, products?, coupons?, tax_rates? }`. **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently, then the catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. **`currency`** is an ISO code (validated against the shared currencies table, **400** `invalid_payload` on an unknown one) that sets `general.currency` plus the currency's standard `num_decimals`; unlike `store_name` it **always wins** — there is no blank state to distinguish a merchant's USD from the seeded default, so passing it on a re-run updates the store currency.
185
+ Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, with_sample_data?, products?, coupons?, locations? }`. **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently, then the catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. **`currency`** (ISO code, validated against the shared currencies table) and **`weight_unit`/`dimension_unit`** set the `general` group; unknown values fail as **400** `invalid_payload`. Unlike `store_name` they **always win** — there is no blank state to distinguish a merchant's choice from the seeded default, so passing one on a re-run updates the store. Prices are formatted with `Intl.NumberFormat` — the currency is a value, there are no format settings.
189
186
 
190
- **`products`** is the one-call catalog bootstrap — the worked example and full semantics are in [`../post-installation.md` §2.1](../post-installation.md#21-the-products-payload). Each entry references categories/tags/attributes by **display name** (taxonomy is get-or-created: slugs/codes derived, existing records matched case-insensitively and reused, with the stored casing canonicalized into the product). `attributes: [{ name, options }]` (or `{ <name>: [options] }`) declares the variant axes; `variations: [{ options: { <name>: <option> }, ...overrides }]` lists the stocked combinations — omit it to auto-generate **all** combinations, each inheriting the product-level price/sale fields. A variation with its own `stock_quantity` gets `manage_stock: "yes"`; without one it draws on the parent's pooled stock (`"parent"`). Variation SKUs are synthesized from the parent SKU when absent. Parent `price`/`regular_price`/`on_sale` are rolled up from the cheapest publishable variant, `stock_status` derived, category/tag counts maintained — same helpers as `admin-products` `save`, but **no `product.created` webhooks are dispatched** (bootstrap precedes webhook subscribers; use `admin-products` for webhook-visible creates). Payload problems fail before any write as **400** `invalid_payload` with `errors: [{ path, error }]`; an explicit variation SKU already in use is **409** `duplicate_sku`; a mid-write failure rolls back everything the call created (**500** `catalog_seed_failed`) without touching reused taxonomy. **Re-runs converge**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported, so retries never duplicate. Limits: ≤100 products, ≤50 variations per product, ≤500 variations per call, ≤50 coupons/tax rates. `coupons`/`tax_rates` are thin passthroughs (coupon `code` lowercased; both skip-if-exists). `with_sample_data: true` seeds the template's demo catalog instead (only when the store has zero products) and cannot be combined with the catalog keys.
187
+ **`products`** is the one-call catalog bootstrap — the worked example and full semantics are in [`../post-installation.md` §2.1](../post-installation.md#21-the-products-payload). Each entry references categories/ribbons/attributes by **display name** (taxonomy is get-or-created: slugs/codes derived, existing records matched case-insensitively and reused, with the stored casing canonicalized into the product). `attributes: [{ name, options }]` (or `{ <name>: [options] }`) declares the variant axes; `variations: [{ options: { <name>: <option> }, ...overrides }]` lists the stocked combinations — omit it to auto-generate **all** combinations, each inheriting the product-level price/sale fields. A variation with its own `stock_quantity` gets `manage_stock: "yes"`; without one it draws on the parent's pooled stock (`"parent"`). Variation SKUs are synthesized from the parent SKU when absent. Parent `price`/`regular_price`/`on_sale` are rolled up from the cheapest publishable variant, `stock_status` derived, category/ribbon counts maintained — same helpers as `admin-products` `save`, but **no `product.created` webhooks are dispatched** (bootstrap precedes webhook subscribers; use `admin-products` for webhook-visible creates). Payload problems fail before any write as **400** `invalid_payload` with `errors: [{ path, error }]`; an explicit variation SKU already in use is **409** `duplicate_sku`; a mid-write failure rolls back everything the call created (**500** `catalog_seed_failed`) without touching reused taxonomy. **Re-runs converge**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported, so retries never duplicate. Limits: ≤100 products, ≤50 variations per product, ≤500 variations per call, ≤50 coupons/locations. `coupons` is a thin passthrough (code lowercased, skip-if-exists); **`locations`** creates Shipping & Tax Locations (`{ name, countries?: ["IL"], shipping_rates: [{ name, cost, free_over? }], tax_groups?: [{ name, rates: [{ name, rate }] }], shipping_tax?: { type: "percent"|"fixed", value } }` — the default `Products` tax group is added when missing; skip-if-exists by name). Passing any `locations` suppresses the seeded "Rest of the world" fallback — yours become the store's only locations; without them the free-shipping fallback is seeded. `with_sample_data: true` seeds the template's demo catalog instead (only when the store has zero products) and cannot be combined with the catalog keys.
191
188
 
192
- → `{ seeded: { settings_groups, gateways, tax_classes, zones, zone_methods }, sample_data: {...} | false, catalog: { categories|tags|attributes|terms: { created, reused }, products_created, products_skipped, variations_created, coupons, tax_rates, products: [{ name, id, slug, sku, variation_count } | { name, skipped: true, reason: "sku_exists"|"slug_exists", existing_id }] } | null, store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" }, currency: { value, action: "created" | "updated" | "unchanged" } | null }`.
189
+ → `{ seeded: { settings_groups, gateways, locations }, sample_data: {...} | false, catalog: { categories|ribbons|attributes|terms: { created, reused }, products_created, products_skipped, variations_created, coupons, locations, products: [{ name, id, slug, sku, variation_count } | { name, skipped: true, reason: "sku_exists"|"slug_exists", existing_id }] } | null, store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" }, currency: { value, action: "created" | "updated" | "unchanged" } | null }`.
@@ -12,7 +12,7 @@ Read this list before writing any of it. Each item is a hard requirement enforce
12
12
  | 2 | **Present shipping options and send a choice** — drive it off the cart's `shipping_status`; a single option is auto-applied, several mean you must ask | `place-order` → `400 shipping_method_required` ([shipping](#shipping-is-not-optional--read-this-before-building-checkout)) |
13
13
  | 3 | **Redirect to `payment.checkout_url`** when the customer pays by card | the order stays `pending` otherwise |
14
14
  | 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
- | 5 | **Only advertise offers the store is configured for** (free-shipping thresholds come from zone methods) | nothing — this one is on you |
15
+ | 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
16
 
17
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.
18
18
 
@@ -22,21 +22,21 @@ The backend is considerably richer than a minimal "grid → cart → pay" shop.
22
22
 
23
23
  | Capability | Already supported | Where |
24
24
  |---|---|---|
25
- | **Product discovery** | full-text `search`, filter by category (incl. descendants), tag, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (the default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
26
- | **Rich product pages** | galleries, per-variant price/stock/image, categories, tags, **upsells**, **cross-sells** | [`get-product`](#get-product) |
27
- | **Taxonomy navigation** | category tree, **tag list with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories), [`list-tags`](#list-tags), [`list-attributes`](#list-attributes) |
28
- | **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by signed-in customers**; plus "my reviews" | [`get-product`](#get-product), [`submit-review`](#submit-review), [`my-reviews`](#commercestorefront-account) |
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) |
26
+ | **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
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
30
  | **Coupons** | apply/remove by code with full server-side validation (eligibility, limits, per-user usage) | [`apply-coupon`](#commercestorefront-cart) |
31
- | **Shipping** | address → matched zone → **selectable shipping methods with live costs**, free-shipping rules | [`set-shipping-address`](#commercestorefront-cart) |
32
- | **Tax** | inclusive/exclusive pricing, per-class rates, itemized or single display — all resolved server-side | [`get-store-info`](#get-store-info) |
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
33
  | **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling, configurable out-of-stock hiding | [`list-products`](#list-products) |
34
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
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
36
  | **Digital products** | entitlement-checked downloads with remaining-count, expiry, and signed URLs for private files | [`get-download`](#commercestorefront-account) |
37
- | **Store config** | currency + formatting, review toggles, catalog/cart price display — read it and honour it instead of hardcoding | [`get-store-info`](#get-store-info) |
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
38
 
39
- Two things that are **not** in the backend and are yours to build: the visitor UI itself, and live card capture (see [`references/online-payments.md`](../references/online-payments.md)). Payment methods, currency and formatting are all admin-owned data — always render them from `get-store-info` rather than a hardcoded list.
39
+ Two things that are **not** in the backend and are yours to build: the visitor UI itself, and the payment provider integration — two files to implement (see [`references/online-payments.md`](../references/online-payments.md)). 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
40
 
41
41
  ## Conventions
42
42
 
@@ -62,31 +62,32 @@ Bootstrap data for a storefront. No payload.
62
62
  **Response:**
63
63
  ```json
64
64
  {
65
- "settings": { "store_name": "Acme", "currency": "USD", "currency_position": "left",
66
- "thousand_sep": ",", "decimal_sep": ".", "num_decimals": 2,
65
+ "settings": { "store_name": "Acme", "currency": "USD",
67
66
  "weight_unit": "kg", "dimension_unit": "cm",
68
67
  "prices_include_tax": false, "display_prices_shop": "excl", "display_prices_cart": "excl",
69
- "enable_reviews": true, "review_rating_required": true, "hide_out_of_stock": false },
68
+ "hide_out_of_stock": false },
70
69
  "payment_gateways": [ { "slug": "offline", "title": "Offline payment", "description": "...", "online": false },
71
- { "slug": "stripe", "title": "Credit card", "description": "...", "online": true } ],
70
+ { "slug": "card", "title": "Credit card", "description": "...", "online": true } ],
72
71
  "countries": [ { "code": "US", "name": "United States", "states": [ { "code": "CA", "name": "California" } ] } ],
73
72
  "currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
74
73
  }
75
74
  ```
76
75
  `settings` is a safe projection — only display/behavior keys, never admin config.
77
76
 
77
+ `payment_gateways` is **every gateway the admin has enabled** (sorted by their `order`) — the Payments settings switch is the single source of truth. `online: true` marks the card option (`place-order` answers with a payment page to redirect to; `503 no_card_payment_provider` while the store has no provider implemented); everything else settles manually.
78
+
78
79
  ### `list-products`
79
- **Payload** (all optional): `search`, `category_id` (includes descendants), `tag_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).
80
+ **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).
80
81
 
81
- Only `status: "publish"` products are returned. Visibility: passing `search` uses **search context** (hides `catalog_visibility: hidden|catalog`); no `search` uses **browse context** (hides `hidden|search`). `inventory.hide_out_of_stock` (or `in_stock_only`) drops out-of-stock products.
82
+ Only `status: "publish"` products are returned — the admin's single **Visible** toggle. `inventory.hide_out_of_stock` (or `in_stock_only`) drops out-of-stock products.
82
83
 
83
84
  **Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
84
85
 
85
- Each row is the product record (minus paywalled fields) **plus a resolved `tags` array** (`[{ id, name }]`), so cards can show tags without a second call. `categories` are **not** resolved on rows — `category_ids` only.
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.
86
87
 
87
- > **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, tags 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.
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.
88
89
 
89
- **Filters stack.** `category_id`, `tag_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-tags`](#list-tags) (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.
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.
90
91
 
91
92
  ### `get-product`
92
93
  **Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
@@ -97,7 +98,7 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
97
98
  "product": { Product },
98
99
  "variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
99
100
  "categories": [ ProductCategory... ],
100
- "tags": [ ProductTag... ],
101
+ "ribbons": [ ProductRibbon... ],
101
102
  "reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
102
103
  "page": 1, "per_page": 10, "has_next": false,
103
104
  "average_rating": 4.5, "rating_count": 12 },
@@ -124,27 +125,27 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
124
125
  ### `list-categories`
125
126
  No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
126
127
 
127
- ### `list-tags`
128
+ ### `list-ribbons`
128
129
  **Payload** (optional): `{ with_products_only?: boolean }` (default `true`).
129
130
 
130
- Returns `{ "tags": [ { id, name, count } ] }` sorted by name. By default it omits tags no published product carries, so a tag nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
131
+ 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).
131
132
 
132
- `count` is tallied from the products this API would actually list — published, browse-visible, and honoring `inventory.hide_out_of_stock` — **not** from `ProductTag.count`, which also counts drafts and hidden products and drifts until `commerce/admin-tools` `recount-terms` runs. So `Gift (12)` and the tag's listing agree.
133
+ `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.
133
134
 
134
- This is the **only** way a storefront can enumerate tags — `commerce.ProductTag` is admin-only RLS — and it's what makes `list-products` `tag_id` usable, since that filter needs an id. Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Show both: see [*Tags*](../references/product-render.md#2-tags--render-them-in-both-views) for where they belong in each view.
135
+ 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.
135
136
 
136
137
  ### `list-attributes`
137
138
  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.
138
139
 
139
- ### `submit-review` — **auth**
140
+ ### `submit-review`
140
141
  **Payload:** `{ product_id, reviewer?, review, rating }`.
141
142
 
142
- **Requires a signed-in customer.** The reviewer's email is taken from the session, never from the payload — a `reviewer_email` field is ignored. `reviewer` is the display name only, and defaults to the account's `full_name`. Build the review form behind a login prompt: anonymous callers get `401 login_required`.
143
+ **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.
143
144
 
144
- Requires `products.enable_reviews` — reviews are a store-wide switch, not per product. Rating required when `review_rating_required`. If `only_verified_reviews`, the signed-in customer must have a `processing`/`completed` order containing the product. `verified` is derived from their own order history. Status is `hold` unless `auto_approve_reviews`.
145
+ 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.
145
146
 
146
147
  **Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
147
- **Errors:** `401 login_required`, `403 reviews_disabled|verified_only|forbidden`, `404 not_found`, `400 review_incomplete|rating_required|invalid_rating`.
148
+ **Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
148
149
 
149
150
  ---
150
151
 
@@ -169,9 +170,9 @@ Token-scoped cart (guest + member). Every action **except `create`** takes `cart
169
170
  "coupon_notices": [ { "code": "old", "error": "Coupon has expired.", "error_code": "expired" } ],
170
171
  "removed_items": [ { "item_key": "...", "product_id": "...", "reason": "...", "code": "unavailable" } ],
171
172
  "shipping_address": { "country": "US", "state": "CA", "postcode": "90210", "city": "LA" },
172
- "chosen_shipping_method": "<zoneMethodId>",
173
- "available_shipping_methods": [ { "id": "<zoneMethodId>", "method_id": "flat_rate", "title": "Flat rate", "cost": 5 } ],
174
- "shipping_status": "chosen", // not_needed | chosen | auto_selected | choice_required | none_available
173
+ "chosen_shipping_method": "<shippingRateId>",
174
+ "available_shipping_methods": [ { "id": "<shippingRateId>", "title": "Standard delivery", "cost": 5 } ],
175
+ "shipping_status": "chosen", // not_needed | chosen | auto_selected | choice_required | missing_address | none_available
175
176
  "totals": { "subtotal": 40, "discount_total": 4, "discount_tax": 0, "shipping_total": 5, "shipping_tax": 0,
176
177
  "cart_tax": 3.6, "total_tax": 3.6, "total": 44.6, "prices_include_tax": false,
177
178
  "tax_lines": [ { "rate_id": "...", "label": "CA Tax", "tax_total": 3.6 } ] },
@@ -186,15 +187,17 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
186
187
  | `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
187
188
  | `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
188
189
  | `totals` | `{ cart_token }` | Alias of `get`. |
189
- | `add-item` | `{ cart_token, product_id, variation_id?, quantity?, attributes? }` | 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`. |
190
+ | `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`. |
190
191
  | `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
191
192
  | `remove-item` | `{ cart_token, item_key }` | |
192
193
  | `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
193
194
  | `remove-coupon` | `{ cart_token, code }` | |
194
- | `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched zone's `available_shipping_methods` and a `shipping_status`. |
195
- | `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a `commerce.ShippingZoneMethod` id from `available_shipping_methods`. `400 invalid_shipping_method` (the error body carries `available_shipping_methods`). |
195
+ | `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. |
196
+ | `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`). |
197
+
198
+ 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.
196
199
 
197
- Common: `400 cart_token_required`, `404 cart_not_found`, `404 cart_expired`.
200
+ > **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.
198
201
 
199
202
  ### Shipping is not optional — read this before building checkout
200
203
  <a id="shipping-is-not-optional--read-this-before-building-checkout"></a>
@@ -209,33 +212,36 @@ rather than assuming:
209
212
  | `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 |
210
213
  | `chosen` | the customer's pick is still offered | show it, allow changing |
211
214
  | `choice_required` | **several** methods are offered and none is chosen | render `available_shipping_methods` and call `choose-shipping-method`; checkout will refuse until then |
212
- | `none_available` | nothing ships to this address | say so, and don't let checkout proceed |
215
+ | `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 |
216
+ | `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) |
213
217
 
214
218
  Rules the cart enforces on every price, so you get them for free:
215
219
 
216
220
  - **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.
221
+ - **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.
222
+ - **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.
217
223
  - **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.
218
- - **Costs change with the cart.** `available_shipping_methods` is recomputed per price — free-shipping thresholds and coupon-gated methods appear and disappear — so re-read it after every cart mutation, not just after setting the address.
224
+ - **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.
219
225
 
220
226
  ### Never advertise what the store isn't configured to do
221
227
 
222
228
  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.
223
229
 
224
- **Zones are admin-only data.** `commerce.ShippingZone` and `commerce.ShippingZoneMethod` are `read: {role: "admin"}` like every entity, so a visitor cannot list them — do not try. Two honest sources:
230
+ **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:
225
231
 
226
232
  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.
227
- 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):
233
+ 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):
228
234
 
229
235
  ```js
230
236
  import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
231
237
 
232
- // zones/methods come from your own storefront action — NOT from a client entity read
233
- const threshold = freeShippingThreshold(freeShippingRules(zones, methods));
238
+ // locations come from your own storefront action — NOT from a client entity read
239
+ const threshold = freeShippingThreshold(freeShippingRules(locations));
234
240
  // null → the store has no free-shipping rule: show no banner, no progress bar.
235
- const progress = freeShippingProgress(threshold, cart.totals.subtotal); // { threshold, qualifies, remaining }
241
+ const progress = freeShippingProgress(threshold, cart.totals.subtotal - cart.totals.discount_total);
236
242
  ```
237
243
 
238
- A free-shipping rule is a `free_shipping` zone method whose `settings.requires` is `min_amount`/`either`/`both` with a `min_amount` (a `coupon` requirement is **not** a spend threshold, and thresholds are **per zone** — a rule in one zone 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.
244
+ 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.
239
245
 
240
246
  ---
241
247
 
@@ -247,21 +253,22 @@ Converts a cart into an order. **Payload:**
247
253
  { "cart_token": "uuid", "payment_method": "offline",
248
254
  "billing": { "first_name", "last_name", "address_1", "address_2?", "city", "state?", "postcode?", "country", "email", "phone?" },
249
255
  "shipping": { ...address without email },
250
- "chosen_shipping_method?": "<zoneMethodId>",
256
+ "chosen_shipping_method?": "<shippingRateId>",
251
257
  "customer_note?": "...", "create_account?": false }
252
258
  ```
253
259
  **Mandatory fields:**
254
260
  - **Billing:** `first_name, last_name, address_1, city, country, email`.
255
261
  - **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:
256
262
  ```js
263
+ // the moment the customer provides an address — this recalculates the options and cost;
264
+ // an unsupported address fails right here with 400 shipping_not_available
257
265
  cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token, address });
258
266
  if (cart.shipping_status === "choice_required") {
259
267
  // MUST render cart.available_shipping_methods and let the customer pick
260
268
  cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token, method_id: picked.id });
261
- } else if (cart.shipping_status === "none_available") {
262
- // the store doesn't ship there — stop, don't try to place the order
263
269
  }
264
270
  // auto_selected / chosen: nothing to do, one option was applied for you
271
+ // (a single-location store may show auto_selected before any address is set)
265
272
  ```
266
273
  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:
267
274
  - **one method offered** → applied automatically, nothing to send;
@@ -271,9 +278,9 @@ Converts a cart into an order. **Payload:**
271
278
 
272
279
  The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
273
280
 
274
- > **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Card payments are **implemented**: choosing the online gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, session_id }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `verify`. Never treat a return URL as proof of payment. The `offline` gateway settles outside the store and returns `payment_instructions` instead. An online gateway is only listed while a payment provider is connected, so anything `get-store-info` offers is payable. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
281
+ > **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).
275
282
 
276
- **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: **offline → on-hold** (with `payment_instructions`), **stripe → stays pending** until the hosted page is paid, **custom → pending_external**.
283
+ **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`).
277
284
 
278
285
  > **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.
279
286
 
@@ -284,15 +291,14 @@ Converts a cart into an order. **Payload:**
284
291
  "status": "on-hold", "currency": "USD",
285
292
  "payment_method": "offline", "payment_method_title": "Offline payment",
286
293
  "payment_instructions": { "type": "offline", "description": "...", "account_details": [...] },
287
- "payment": null, // offline. For the online gateway:
288
- // { "status": "requires_payment", "provider", "checkout_url", "session_id" }
289
- // For a custom gateway: { "status": "pending_external" }
294
+ "payment": null, // manual gateways. For the card gateway:
295
+ // { "status": "requires_payment", "checkout_url", "reference" }
290
296
  "notices": [], // e.g. ["account_creation_requires_login"]
291
297
  "totals": { "subtotal", "discount_total", "shipping_total", "shipping_tax", "cart_tax", "total_tax", "total" },
292
298
  "order": { ...customer-safe order (internal flags/ip stripped) }
293
299
  }
294
300
  ```
295
- **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`.
301
+ **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).
296
302
 
297
303
  ### `commerce/payments` — online payment for an order
298
304
 
@@ -300,23 +306,23 @@ Separate function, same guest-bearer rule (`order_id` + `order_key`; an admin ma
300
306
 
301
307
  | Action | Payload | Returns |
302
308
  |---|---|---|
303
- | `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ provider, session_id, url, expires_at? }` — a fresh hosted payment page for an **unpaid** order, whatever its current payment method (the order is switched onto the online 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 online_payments_disabled` (the store turned card payment off), `503 payment_provider_unavailable` |
304
- | `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link }` — the whole return flow in one call for your `/order-received` page: confirms with the provider, progresses the order, and includes a fresh `payment_link` while unpaid. `payment` is only a hint; a hand-edited `?payment=success` can never yield `paid` |
305
- | `verify` | `{ order_id, order_key, session_id? }` | `{ 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. A `session_id` you pass must be one the provider opened for *this* order, or `409 session_order_mismatch`; omit it and the order's own session is used |
309
+ | `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` |
310
+ | `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 |
311
+ | `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 |
306
312
 
307
313
  > **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).
308
314
 
309
- Provider callbacks land on `commerce/payment-webhook` (signed, server-to-server) — the second confirmation path, for buyers who pay and close the tab. Whichever path runs second is a no-op.
315
+ 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. Wiring a payment provider means implementing two files; see [`../references/online-payments.md`](../references/online-payments.md).
310
316
 
311
317
  ### `confirm-payment`
312
- Post-payment hook for an order on the online gateway. **Payload:** `{ order_id, order_key, session_id? }`.
318
+ Post-payment hook for an order on the card gateway. **Payload:** `{ order_id, order_key }`.
313
319
 
314
- **The request cannot make an order paid.** The `order_key` says who is asking; the payment itself is confirmed against the provider, and the session it names must be one the provider opened for *this* 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.
320
+ **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.
315
321
 
316
322
  `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.
317
323
 
318
324
  **Response:** `{ "order": { ...customer-safe order }, "paid": true, "already_confirmed": false }`
319
- **Errors:** `400 order_key_required|not_an_online_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed|session_order_mismatch`.
325
+ **Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
320
326
 
321
327
  ### `cancel-order`
322
328
  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`.
@@ -364,7 +370,9 @@ if ((detail.product.attributes ?? []).length) {
364
370
  // 4. Coupon (optional)
365
371
  cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token: token, code: "welcome10" });
366
372
 
367
- // 5. Shipping address → method. A single option is already applied for you;
373
+ // 5. Shipping address → method. Call this as soon as the address is known — it
374
+ // recalculates the options and cost; an unsupported address fails right here
375
+ // (400 shipping_not_available). A single option is already applied for you;
368
376
  // several mean the customer has to choose or checkout will refuse.
369
377
  cart = await inv("commerce/storefront-cart", { action: "set-shipping-address",
370
378
  cart_token: token, address: { country: "US", state: "CA", postcode: "90210", city: "Los Angeles" } });
@@ -372,8 +380,6 @@ if (cart.shipping_status === "choice_required") {
372
380
  const picked = await askCustomer(cart.available_shipping_methods); // your UI
373
381
  cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method",
374
382
  cart_token: token, method_id: picked.id });
375
- } else if (cart.shipping_status === "none_available") {
376
- throw new Error("We don't ship to this address");
377
383
  }
378
384
 
379
385
  // 6. Place the order (offline → on-hold; no money has arrived yet)