@base44/app-plugin-commerce 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "commerce.PaymentGateway",
3
3
  "type": "object",
4
- "description": "Payment gateway configuration. Seeded: offline (settled outside the store) and card (processed by commerce/payments + commerce/payment-webhook via shared/commerce/card-payment.ts). Secrets are never stored here; use Base44 secrets.",
4
+ "description": "Payment gateway configuration. Seeded: offline (settled outside the store) and card (processed by commerce/payments + commerce/payment-webhook via shared/commerce/card-payment.ts). Enable or disable a gateway with commerce/seed-store payment_methods — e.g. { payment_methods: [\"offline\", \"card\"] }, valid at any time and the whole payload — never by writing enabled on this row: the seeder owns the on/off state and the next call overwrites a hand edit. Secrets are never stored here; use Base44 secrets.",
5
5
  "properties": {
6
6
  "slug": {
7
7
  "type": "string",
@@ -47,6 +47,14 @@
47
47
  * the one call covers "card-only" stores with no entity editing. Explicit
48
48
  * values always win, on first seed and re-runs alike (like `currency`).
49
49
  *
50
+ * **Every key is independent, and the call is the day-2 tool as much as the
51
+ * install one.** A later call carries only the slice being changed — enabling
52
+ * cards on a live store is `{ payment_methods: ["offline", "card"] }` and
53
+ * nothing else. Omitted keys are not reset: no `products` means no catalog work
54
+ * at all, and no `locations` leaves the store's shipping untouched. This is the
55
+ * supported way to change store configuration; editing `commerce.PaymentGateway`
56
+ * (or the settings rows) by hand is not.
57
+ *
50
58
  * `store_name` is required on a first seed: a function's env is only
51
59
  * BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
52
60
  * `currency` is an ISO code and `weight_unit`/`dimension_unit` are the
@@ -249,9 +257,15 @@ Deno.serve(async (req) => {
249
257
  if (catalogSpec?.locations?.length && !catalogSpec.locations.some((l: any) => !l.regions?.length)) {
250
258
  warnings.push("no_catchall_location: addresses outside your locations will get shipping_not_available — add a location with rest_of_world: true if you ship worldwide");
251
259
  }
260
+ // The same reasoning applies to a later call that simply doesn't mention
261
+ // shipping — flipping `payment_methods` on a live store, say. The merchant's
262
+ // locations are still the shipping story, so the test is whether the store
263
+ // has ANY location, not whether one carries this default's name: matching by
264
+ // name would drop a free worldwide rate behind a catch-all called anything
265
+ // else, which is the exact record the paragraph above refuses to create.
252
266
  if (!catalogSpec?.locations?.length) {
253
- const fallback = ((await sr.entities["commerce.ShippingTaxLocation"].filter({ name: REST_OF_WORLD_LOCATION.name }, undefined, 1)) ?? [])[0];
254
- if (!fallback) {
267
+ const existing = (await sr.entities["commerce.ShippingTaxLocation"].list(undefined, 1)) ?? [];
268
+ if (!existing.length) {
255
269
  await sr.entities["commerce.ShippingTaxLocation"].create(REST_OF_WORLD_LOCATION);
256
270
  seeded.locations++;
257
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -33,8 +33,11 @@ moments. Read nothing else up front.
33
33
  `base44.entities["commerce.Product"]`. `commerce__Product` and `Product` do
34
34
  not exist. The map is [`docs/entities.md`](./docs/entities.md); never scan
35
35
  `base44/entities/` for a name.
36
- - **Store configuration is declared in one seed call** — catalog, shipping
37
- zones, currency, payment methods — never assembled by editing records
36
+ - **Store configuration is declared through `commerce/seed-store`** — catalog,
37
+ shipping zones, currency, payment methods — never assembled by editing
38
+ records. It is idempotent and every key is independent, so it is the day-2
39
+ tool as much as the install one: a later call carries only the slice it
40
+ changes — enabling cards is `{ payment_methods: ["offline", "card"] }`, whole
38
41
  ([`install/03-data.md`](./install/03-data.md)).
39
42
  - **Don't weaken the admin gating** — three layers (UI guard, entity RLS,
40
43
  `requireAdmin()`); keep all three when touching routes or schemas
@@ -105,13 +108,13 @@ Open a file when its work starts — not while planning.
105
108
 
106
109
  | Topic | Open when | Size |
107
110
  |---|---|---|
108
- | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 6K |
111
+ | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 5K |
109
112
  | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 33K |
110
- | [`install/03-data.md`](./install/03-data.md) | seeding catalog/shipping, payments decision | 11K |
113
+ | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
111
114
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
112
115
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | 13K |
113
116
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
114
- | [`references/online-payments.md`](./references/online-payments.md) | the store opted into cards and you are wiring the provider **now** | 8K |
117
+ | [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider at install or any time later | 8K |
115
118
  | [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
116
119
  | [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
117
120
  | [`references/store-settings.md`](./references/store-settings.md) | changing store behavior through settings keys | 5K |
@@ -67,9 +67,4 @@ Storefront functions are public on purpose (per-action verification, above). To
67
67
 
68
68
  Then continue: **[`./02-storefront.md`](./02-storefront.md) when you start building UI**, **[`./03-data.md`](./03-data.md) when you start the seed payload**. Do **not** read them now, and open no `references/` or `docs/` file during an install — a file read early costs its size on every later call.
69
69
 
70
- Record these lines in your working notes; do not re-read this file.
71
-
72
- - Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them.
73
- - `/order-received` must exist as a route: every payment link returns there, and confirming is what marks an order paid.
74
- - Interleave: start image generation first → mount admin + build the storefront while images render → seed when the URLs are back → payments last.
75
- - Entities are dotted + bracket-syntax only (`base44.entities["commerce.X"]`); the map is [`../docs/entities.md`](../docs/entities.md) — never scan `base44/entities/`.
70
+ Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
@@ -16,11 +16,11 @@ carry_forward:
16
16
 
17
17
  # 02 — Storefront
18
18
 
19
- One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup, carries CSS, or contains a sentence a customer will read. **Decide how the store looks as if this kit did not exist**, then encode that look **once** as design classes ([below](#design-language--once-before-any-page)) and build every page in them — the snippets here are wiring reference, never design input.
19
+ One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup, carries CSS, or contains a sentence a customer will read. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) — the snippets here are wiring reference, never design input.
20
20
 
21
21
  **States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
22
22
 
23
- **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — you do not need to open the hook files while building, and doing so mid-build is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
23
+ **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
24
24
 
25
25
  **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out — it is the only source of store name and currency.
26
26
 
@@ -45,13 +45,23 @@ import AdminApp from "@/commerce/admin";
45
45
  }>
46
46
  <Route path="/" element={<Home />} />
47
47
  <Route path="/product/:slug" element={<ProductPage />} />
48
- <Route path="/bag" element={<Bag />} />
49
- <Route path="/checkout" element={<Checkout />} />
50
- <Route path="/order-received" element={<OrderReceived />} />
48
+ {/* /bag, /checkout, and /order-received — which is mandatory */}
51
49
  </Route>
52
50
  <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
53
51
  </Routes>
54
52
  </BrowserRouter>
53
+
54
+ // …and the layout that route renders. Yours to design; the shape is the point:
55
+ function StoreLayout() {
56
+ const { itemCount } = useCart(); // one cart, shared with every page
57
+ return (
58
+ <>
59
+ <header>{/* nav + your cart trigger, showing itemCount */}</header>
60
+ <Outlet /> {/* the routed page lands here */}
61
+ <footer>…</footer>{/* + the drawer, if the cart is one — see Cart below */}
62
+ </>
63
+ );
64
+ }
55
65
  ```
56
66
 
57
67
  ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse** — a layout that renders the provider inside itself leaves the nav's cart badge on a different cart (or throws). With no shared chrome, wrap `<Routes>` in the provider instead; a provider *inside* `<Routes>` throws ("is not a `<Route>` component"). The provider owns the shared client, store info and **one** shared cart — never mount a second one, never touch `cart_token`.
@@ -60,9 +70,9 @@ import AdminApp from "@/commerce/admin";
60
70
 
61
71
  The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`, a heading scale, a price style — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.**
62
72
 
63
- The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show — rather than inventing a phrase per call site. It is the half of the store's identity a kit cannot ship: "Add to bag" in every store built from this plugin is exactly how stores end up reading like each other.
73
+ The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship: "Add to bag" in every store built from this plugin is how they end up reading alike.
64
74
 
65
- **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. The signature budget concentrates where the products are: **the product page stays the storefront's richest surface**, and its rules to render axes and specs *by what they are* hold at full force — that richness is semantic (what the controls and rows show), which costs words, not chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
75
+ **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
66
76
 
67
77
  ## What each hook resolves to
68
78
 
@@ -87,7 +97,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
87
97
  | `cartTotalsLines(cart, { formatMoney })` | `[{ key, label, amount, formatted, hidden, emphasis }]` — every line the store has, incl. discount and tax. `orderTotalsLines(order, …)` is the same shape for a receipt. Pass `labels: {…}` to rename a row. |
88
98
  | `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
89
99
  | `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
90
- | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping state (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`, `shippingSyncing`), the payment state (`paymentMethods`, `paymentMethod`, `setPaymentMethod`, `selectedGateway`, `singlePaymentMethod`) and the gate (`blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder`) — plus `cart`. The pickers below read the shipping/payment parts for you. |
100
+ | `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping and payment state (the pickers read those for you), and the gate: `blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder` — plus `cart`. The Checkout section wires all of it. |
91
101
  | `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`. An order's totals are **flat** (`order.total`); there is no `order.totals`. |
92
102
 
93
103
  ## Product list / collection
@@ -136,7 +146,7 @@ Build your layout from — all optional, **not one component style**:
136
146
  useEffect(() => setPicked(null), [view?.variation?.id]); // a new variant re-takes the lead
137
147
  ```
138
148
 
139
- ⚑ **Render the main frame from `view.display.image`, not from an index into the strip.** A variation's own image lives on the variation and is usually *not* in `product.images`, so indexing the strip shows the wrong colour the moment a variant is picked. `display.image` is variation-first with a parent fallback and is exactly the same shape as a strip entry (`{src, name, alt}`), which is why `imageIndex` can locate it when it *is* there and return `-1` when it isn't. `active === null` only when the product has no images at all — the one placeholder case.
149
+ ⚑ **The main frame comes from `view.display.image`, never from an index into the strip.** A variation's image lives on the variation and is usually *not* in `product.images`, so indexing the strip shows the wrong colour as soon as a variant is picked. `display.image` is variation-first with a parent fallback, in the same `{src, name, alt}` shape as a strip entry — which is why `imageIndex` locates it when it is there and returns `-1` when it isn't. `active === null` only when the product has no images at all.
140
150
  - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
141
151
 
142
152
  ```jsx
@@ -175,7 +185,7 @@ import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLa
175
185
 
176
186
  A cart *page* is optional — decide from what the store sells (buy-now straight to checkout reads better for a single-piece store; a grocery basket needs a page).
177
187
 
178
- ⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout): `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that message inline. Coupons are admin-only data, redeemable only through a field the customer types into; if none exists, don't seed coupons and don't write "use WELCOME10" in the copy.
188
+ ⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout) — they are admin-only data, redeemable only through a field the customer types into. `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that inline. No field means no coupons: don't seed them, don't name a code in the copy.
179
189
 
180
190
  ⚑ **`pending` is one row's flag**: it goes true when that row's debounced request leaves and false only after the new cart view lands — so disable and mark only that row (`disabled={l.pending}`, `aria-busy` on the row), never the whole cart. `status` never returns to `"loading"` for a mutation; there is deliberately no cart-wide busy flag. ⚑ **Repeated controls need unique accessible names** — three "Remove" buttons name nothing; put the line in the label.
181
191
 
@@ -207,7 +217,7 @@ const formatMoney = useFormatMoney();
207
217
  ))}
208
218
  ```
209
219
 
210
- No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name (a rename must not break the match). ⚑ **A one-click Add only works on a product with no attributes** one that sells variants needs a `variation_id`, so `addItem({ product_id })` answers `400 variation_required`; link those tiles to the product page instead of adding from the rail.
220
+ No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name (a rename must not break the match). ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
211
221
 
212
222
  ### If the cart is a drawer
213
223
 
@@ -307,7 +317,7 @@ function AddressFields({ which }) {
307
317
  }
308
318
  ```
309
319
 
310
- Passing `country` is what makes the **state/province field appear, with the right options**, for the US, Canada and Australia. **Never omit that field**: shipping rates and taxes match on country *plus* state, so a form without it mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token it is what makes browser autofill work. Labels here are plain conventions; rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks arm them on first edit rather than on load, so an untouched form doesn't open covered in "required" marks.
320
+ ⚑ **Passing `country` is what makes the state/province field appear**, with the right options for the US, Canada and Australia and shipping rates and taxes match on country *plus* state, so a form without that field mis-prices those orders with no error anywhere. ⚑ Keep each field's `autoComplete` token; it is what makes browser autofill work. Labels are plain conventions rename or restyle freely. `c.missingBillingFields` is the live list of what is still missing, if you want per-field marks; arm them on first edit, not on load.
311
321
 
312
322
  ⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
313
323
 
@@ -22,7 +22,7 @@ A fresh install has **no settings and no catalog**. One admin-only, idempotent c
22
22
 
23
23
  **`store_name` is required on a first seed** — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). **`currency`** is an ISO code (`"EUR"`); formatting follows the viewer's locale, nothing else to set. Explicit values always win, first seed and re-runs alike.
24
24
 
25
- The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) is in [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store) — open it only if the catalog needs one of those:
25
+ The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) lives in `api-admin.md` — open it only if the catalog needs one:
26
26
 
27
27
  ```js
28
28
  try {
@@ -45,11 +45,10 @@ try {
45
45
  categories: ["Shoes"], // get-or-created by display name
46
46
  ribbons: ["Best Seller"], // flat labels, not a hierarchy
47
47
 
48
- // Descriptive properties the product page's spec rows (productSpecs
49
- // types each one). NOT variant axes, NOT ribbons. Strings; `_` hides.
48
+ // Descriptive spec rows (productSpecs). NOT axes, NOT ribbons; `_` hides.
50
49
  meta_data: [
51
50
  { key: "Material", value: "Recycled knit upper" },
52
- { key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
51
+ { key: "Weight", value: "248 g" },
53
52
  ],
54
53
 
55
54
  attributes: [ // the axes → one selector each
@@ -76,7 +75,7 @@ try {
76
75
 
77
76
  **Running this through a code-execution tool? Return `res.data`, never the raw response** — the raw response carries circular objects and fails `Converting circular structure to JSON` *even when the seed succeeded*; a thrown error needs `e.response?.data` for the same reason.
78
77
 
79
- Reference taxonomy by **display name** — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's price up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface. **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry. **Limits:** ≤100 products, ≤500 variations, ≤50 locations per call. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]` before anything is written.
78
+ Reference taxonomy by **display name** — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's price up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface. **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]` before anything is written; per-call limits (≤100 products, ≤500 variations, ≤50 locations) and the full key list are in [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store).
80
79
 
81
80
  The response reports everything; these matter downstream:
82
81
 
@@ -94,16 +93,19 @@ Each location is a scope plus its rates and taxes; locations match in payload or
94
93
 
95
94
  ```js
96
95
  locations: [
97
- { name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 }] },
96
+ { name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 },
97
+ { name: "Express", cost: 35 }] },
98
98
  { name: "Worldwide", rest_of_world: true, shipping_rates: [{ name: "International", cost: 100 }] },
99
99
  ]
100
100
  ```
101
101
 
102
+ ⚑ **Speeds go in one location's `shipping_rates`; zones are separate locations.** Exactly one location matches an address, so several rates in it become the customer's pick at checkout (`shipping_status: "choice_required"`), while a second location with the same scope is simply dead — never matched, its rates never offered.
103
+
102
104
  - **`continents: ["EU"]`** spares you a 51-country list — and `EU` is the *continent* Europe, not the European Union.
103
- - **`rest_of_world: true`** is the catch-all. There is **no country code meaning "everywhere"** (`["*"]`, `["ALL"]`, alpha-3 like `["USA"]` all fail **400**); other scopes are `countries: ["IL", "DE"]` or `regions: [{ type: "state", code: "US:CA" }]`. Matching is country + state only.
104
- - A matched location supplies **both** shipping rates and tax groups (`tax_groups`, `shipping_tax`, `free_over` see the reference below).
105
+ - **`rest_of_world: true`** is the catch-all there is **no country code meaning "everywhere"**, and improvised ones (`["*"]`, `["ALL"]`, alpha-3 `["USA"]`) fail **400**. The narrower scope is `countries: ["IL", "DE"]`; matching is country + state only.
106
+ - The matched location supplies the taxes too (`tax_groups`, `shipping_tax`) and `free_over` zeroes a rate above a subtotal — reference below.
105
107
 
106
- **The catch-all trap.** Passing any `locations` suppresses the seeded worldwide fallback, so scoped locations with nothing behind them mean every other address gets `shipping_not_available` at checkout. Plausibly intended, so it is a warning, not an error: **read `warnings` on every seed** and either add a `rest_of_world` location or state the restriction to the user. If the brief named tiered rates, assert them once by pricing a cart against one address per zone (`set-shipping-address` → `available_shipping_methods`; a `[]` means no location matched).
108
+ **The catch-all trap.** Passing any `locations` suppresses the seeded worldwide fallback, so scoped locations with nothing behind them answer `shipping_not_available` to every other address. Plausibly intended, so it warns rather than fails: **read `warnings` on every seed** and either add a `rest_of_world` location or state the restriction to the user. If the brief named tiered rates, price a cart against one address per zone to check them (`set-shipping-address` → `available_shipping_methods`; `[]` means no location matched).
107
109
 
108
110
  Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
109
111
 
@@ -115,7 +117,7 @@ Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edit
115
117
 
116
118
  Online card payments are **off by default**: the seeded store takes the manual `offline` method and is complete and payable — the order goes on-hold and `/order-received` renders the gateway's payment instructions, no code, no credentials. **Enable `card` only if a provider is wired in the same stretch of work**: an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it.
117
119
 
118
- `payment_methods` is the on/off switch — the listed slugs are enabled, every other row disabled; it is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later switch.
120
+ `payment_methods` is the on/off switch — the listed slugs are enabled, every other row disabled. **Never write `enabled` on the `commerce.PaymentGateway` row instead.** **Seeding need not happen all at once:** every key is independent, so re-call `commerce/seed-store` whenever one slice of configuration changes, carrying only that slice — `{ payment_methods: ["offline", "card"] }` is a complete standalone call, as valid on a live store weeks later as during the install (`store_name` is required only on a store's first seed).
119
121
 
120
122
  | The request says | Decision |
121
123
  |---|---|
@@ -17,6 +17,16 @@ What it does **not** ship is a live provider. Wiring one means **one file** —
17
17
 
18
18
  Card payments are **off by default** and are the last thing to add, never the first — the rule, the timing and the decision table live in [`../install/03-data.md`](../install/03-data.md). Everything below assumes that decision is made.
19
19
 
20
+ ## Enabling the gateway — one seed call, at any time
21
+
22
+ ⚑ **Turning `card` on is a `commerce/seed-store` call, never an edit to `commerce.PaymentGateway`.** The gateway row is derived data: the seeder enables every slug you list and disables every one you don't, so writing `enabled` on the record by hand is undone by the next seed and skips the checks the seeder makes.
23
+
24
+ ```js
25
+ await base44.functions.invoke("commerce/seed-store", { payment_methods: ["offline", "card"] });
26
+ ```
27
+
28
+ That is the **whole payload**. `seed-store` is idempotent and every key is independent, so this is as valid on a live store months later as it is during the install: no `products` key means no catalog work, no `locations` key leaves shipping exactly as it is, and `store_name` is only required on a store's very first seed. Drop `"offline"` from the array for a card-only store — the list is the complete set of enabled methods, not an addition to it.
29
+
20
30
  | Function | Backs |
21
31
  |---|---|
22
32
  | `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` | checkout + payment links: a hosted page for `order.total`, returning `{ url, reference }`, with `order.id`/`order.order_key` on the payment's metadata |