@base44/app-plugin-commerce 0.1.16 → 0.1.18
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.
- package/README.md +3 -3
- package/base44/functions/commerce/seed-store/defaults.ts +6 -4
- package/base44/shared/commerce/card-payment.ts +7 -3
- package/base44/shared/commerce/payments.ts +4 -4
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +5 -4
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +3 -3
- package/skills/commerce/installation-guidelines.md +2 -2
- package/skills/commerce/post-installation.md +71 -133
- package/skills/commerce/references/online-payments.md +124 -4
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +9 -7
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
|
|
|
8
8
|
|
|
9
9
|
- **20 entities** — Products (a product sells variants when it carries attributes; no type field), variations, categories, ribbons, attributes + values, reviews, orders (embedded line/shipping/tax/fee/coupon lines), order notes, refunds, coupons, customers, Shipping & Tax Locations (shipping rates + tax groups per location), payment gateways, store settings, webhooks + deliveries, carts, download permissions, email log.
|
|
10
10
|
- **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store` — one call seeds the business defaults **and the whole catalog** (products with attributes in, variants/categories/taxonomy created internally).
|
|
11
|
-
- **
|
|
11
|
+
- **Payments: manual methods work out of the box; online cards are opt-in** — the seed enables the manual `offline` method (bank transfer, cash on delivery, pickup: on-hold + instructions, no code) and leaves the `card` gateway **disabled**. **No payment provider ships with the template.** The order side of card payments *is* premade — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook) and refund records — so a store that opts in wires any provider (Stripe, PayPal, a local PSP…) by implementing **four functions in one file**, `base44/shared/commerce/card-payment.ts`, and nothing else; enable the gateway only with that done, or checkout answers `503 no_card_payment_provider`. The rule and timing: [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §4.1. Provider rules and a complete Stripe implementation to paste, for that one common choice: [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md). The admin can add more manual methods in Settings → Payments.
|
|
12
12
|
- **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
|
|
13
13
|
- **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
|
|
14
14
|
- **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and variant price ranges; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" copy states a configured rule rather than an invented number. See [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md).
|
|
@@ -89,7 +89,7 @@ From your existing Base44 app:
|
|
|
89
89
|
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
90
90
|
```
|
|
91
91
|
6. **Grant yourself the `admin` role** (Base44 dashboard → users, or `users.inviteUser(email, "admin")`). The admin UI refuses non-admins.
|
|
92
|
-
7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups,
|
|
92
|
+
7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, the payment gateway rows (`offline` enabled, `card` off — enable it only with a provider wired) and — unless you pass your own `locations` — a fallback Shipping & Tax Location, plus the catalog: pass `products` (whole products with attributes — variants, categories, ribbons and taxonomy are created internally) or `with_sample_data: true` for the generic demo. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. Payload reference and a worked example: [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.
|
|
93
93
|
|
|
94
94
|
## Quick start (Base44 MCP / hosted apps)
|
|
95
95
|
|
|
@@ -102,7 +102,7 @@ If you build on Base44's hosted platform, use the Base44 agent/MCP to write the
|
|
|
102
102
|
## What's NOT included
|
|
103
103
|
|
|
104
104
|
- **No visitor/storefront UI.** The storefront **API** is complete (`commerce/storefront-*` functions); building the shopfront is up to you — see [`skills/commerce/docs/api-storefront.md`](./skills/commerce/docs/api-storefront.md). What *does* ship for the storefront is **logic, not looks**: [`src/commerce/utils/`](./src/commerce/utils/) (framework-free API client + variant-selection functions — map a Size/Color selection to a `ProductVariation` and back, per-option availability, variant price ranges) and [`src/commerce/storefront/`](./src/commerce/storefront/) (React hooks + headless pickers for the shared cart, the guided checkout with automatic shipping/tax recalculation, and the `/order-received` page — no visual components), plus [`skills/commerce/references/product-render.md`](./skills/commerce/references/product-render.md) (what to render in a grid vs. a product page, and which fields each call returns) and [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md), the variant rules that go with the helpers.
|
|
105
|
-
- **No payment provider
|
|
105
|
+
- **No payment provider — and cards are off by default.** The order side of card payments is premade (see above), but charging a card needs a provider, so `commerce/seed-store` enables the manual **`offline`** method (bank transfer, cash on delivery, pickup — no code, no credentials) and leaves the **`card`** gateway **switched off**. **Enable cards only if a provider is wired, or is about to be** — implement the four functions in `base44/shared/commerce/card-payment.ts` (rules + a Stripe implementation to paste: `skills/commerce/references/online-payments.md`) and enable the gateway via the seed's `payment_methods: ["offline", "card"]`; enabled with nothing behind it, checkout answers `503 no_card_payment_provider`. The rule and why it belongs at the end of a build rather than its start: `skills/commerce/post-installation.md` §4.1.
|
|
106
106
|
- **No scheduled workflows shipped.** Base44 *does* have a scheduler, but this template ships no workflow files — time-based jobs (stock-hold release, cart expiry, webhook-log pruning) run **opportunistically** where possible, and for the rest you (or the Base44 agent) create scheduled workflows that call `commerce/admin-tools`/`commerce/admin-orders` actions — see *Scheduled work* in [`skills/commerce/SKILL.md`](./skills/commerce/SKILL.md).
|
|
107
107
|
|
|
108
108
|
## Next steps
|
|
@@ -99,10 +99,12 @@ export const GATEWAY_DEFAULTS = [
|
|
|
99
99
|
slug: "card",
|
|
100
100
|
title: "Credit card",
|
|
101
101
|
description: "Pay securely by credit card.",
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
102
|
+
// Off by default. Enable it (seed `payment_methods: [..., "card"]`, or the
|
|
103
|
+
// Settings → Payments switch) only when a payment provider is wired in
|
|
104
|
+
// shared/commerce/card-payment.ts, or is about to be in the same stretch of
|
|
105
|
+
// work: enabled means offered, and an enabled card option with no provider
|
|
106
|
+
// behind it answers 503 no_card_payment_provider at checkout.
|
|
107
|
+
enabled: false,
|
|
106
108
|
order: 1,
|
|
107
109
|
method_title: "Credit card",
|
|
108
110
|
method_description: "Card payment on a provider-hosted page.",
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* refundCardPayment → admin refunds through the provider
|
|
9
9
|
* parseWebhook → validate the provider's server-to-server event
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* NO PROVIDER SHIPS WITH THE TEMPLATE, and card payments are off by default
|
|
12
|
+
* (the `card` gateway is seeded disabled) — this file stays stubs unless a
|
|
13
|
+
* store deliberately opts into online cards. When one does, any provider
|
|
14
|
+
* works: `.agents/skills/commerce/references/online-payments.md` has the
|
|
15
|
+
* rules, plus a complete Stripe implementation to paste over this file if
|
|
16
|
+
* Stripe is the provider chosen. Another provider implements the same four
|
|
17
|
+
* functions against its own API.
|
|
14
18
|
*
|
|
15
19
|
* REPLACE THIS FILE WHOLE (one write of the full new content). Never
|
|
16
20
|
* find_replace into these stubs: a partial patch leaves the originals behind
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* The provider-specific half lives in exactly ONE file an agent implements
|
|
5
5
|
* when wiring a provider: `shared/commerce/card-payment.ts` — four functions
|
|
6
6
|
* (create a payment page, check it was paid, refund it, validate a webhook
|
|
7
|
-
* event).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* event). No provider ships with the template and the `card` gateway is
|
|
8
|
+
* seeded disabled, so this half is dormant until a store opts into online
|
|
9
|
+
* cards. Any provider works; the rules — and a complete Stripe
|
|
10
|
+
* implementation, for that one common choice — are in
|
|
11
11
|
* `.agents/skills/commerce/references/online-payments.md`.
|
|
12
12
|
*
|
|
13
13
|
* Everything here — return URLs, storing the payment reference on the order,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
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",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -11,10 +11,11 @@ Operational guidance for extending, operating and building on the Base44 Commerc
|
|
|
11
11
|
> - **Don't read the whole codebase up front.** Start with this file, then open only the files your current task touches plus the matching reference below, pulling in more as you go. Reading everything first just burns context.
|
|
12
12
|
> - **Don't weaken the admin gating.** The UI guard, entity RLS and function guards form three enforcement layers — see [`.agents/skills/commerce/post-installation.md`](./post-installation.md) — keep all of them intact when changing routes or schemas.
|
|
13
13
|
> - **Entity names are dotted — SDK access is bracket syntax only:** `base44.entities["commerce.PaymentGateway"]` (schema file: `base44/entities/commerce.PaymentGateway.jsonc`). `commerce__PaymentGateway` / `PaymentGateway` don't exist. Gateway on/off is seed data anyway: `commerce/seed-store` `payment_methods`.
|
|
14
|
+
> - **Online card payments are optional and off by default.** The seed enables the manual `offline` method and leaves the `card` gateway disabled. **Enable cards only if a payment provider is wired — or will be, in the same stretch of work** (one file): enabled with nothing behind it answers `503 no_card_payment_provider` at checkout. Choosing to add them is fine, but it belongs at the *end* of an install, once there's a working store to point at — nothing else depends on it, so never open a build by asking a user for a provider key. [`post-installation.md` §4.1](./post-installation.md#41-off-by-default--enable-only-with-a-provider-wired) has the rule and the timing.
|
|
14
15
|
|
|
15
16
|
## IMPORTANT — first-time installation
|
|
16
17
|
|
|
17
|
-
If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §3) — **card payments
|
|
18
|
+
If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §3) — **card payments as an optional, late step** (§4.1: off by default — the seed enables offline payment and leaves cards off; **no payment provider ships with the kit**, so cards mean deliberately wiring one, raised at the end of the install and never at its start; §4.2 is the four-step how-to, and the provider code itself lives in [`references/online-payments.md`](./references/online-payments.md) — read it only if the store opts in), and the **storefront quick start** (§2): logic-only chunks for product list → product page → optional cart → checkout. Its §0 schedules the whole install: **storefront components are written while image generation and the seed call (parallel writes — usually a few seconds) run — never after them, and never behind a payment-provider round-trip**. The references below and the API docs are for requests that go beyond that happy path, not for the install. The full install-from-scratch steps are in [`.agents/skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
|
|
18
19
|
|
|
19
20
|
## Working on the UI
|
|
20
21
|
|
|
@@ -35,9 +36,9 @@ Agents keep shipping storefronts that miss these, and each one breaks buying out
|
|
|
35
36
|
|
|
36
37
|
2. **Checkout must recalculate shipping from the address and send a choice.** `useCheckout` does all of it: it calls `set-shipping-address` automatically once the address is complete (recalculating options, cost and tax; `400 shipping_not_available` → its `addressError`), resolves `shipping_status`, and blocks `placeOrder` until the choice is made. Hand-rolled flows must do the same by hand: call `set-shipping-address` as soon as the customer provides an address, then read `shipping_status` on the returned cart — `auto_selected` (one option, already applied) · `chosen` · `choice_required` → **show `available_shipping_methods` and call `choose-shipping-method`** · `missing_address` → collect the address first (a single-location store shows its options even before one). `place-order` refuses with `400 shipping_method_required` until then — that is not a bug to work around. (`chosen_shipping_method` on the cart is the rate **id**, a string — display it by looking up its entry's `title`/`cost`, never by rendering the id.)
|
|
37
38
|
|
|
38
|
-
3. **Handle card payments, and build `/order-received
|
|
39
|
+
3. **Handle card payments if the store offers them, and build `/order-received` either way.** Render whatever gateways `get-store-info` reports — a default-seeded store offers `offline` only (cards are off by default, and enabled only with a provider wired: [`post-installation.md` §4.1](./post-installation.md#41-off-by-default--enable-only-with-a-provider-wired)), so never hardcode a card option. Choosing the card gateway returns `payment.checkout_url` — `useCheckout().placeOrder` redirects there for you (with no provider implemented — one file, [`post-installation.md` §4.2](./post-installation.md#42-wiring-a-provider--one-file) — it answers `503 no_card_payment_provider`; offer the other methods). Every payment link comes back to `/order-received`, which **you must implement**: one `useOrderReturn()` call rendered by `status` (hand-rolled: `commerce/payments` `complete-return` with the query params). Without that page a customer pays into a 404 and the order is never marked paid. Also persist the `cart_token` from **every** cart response (the provider/client do this) — `add-item` silently starts a fresh cart when the stored token is stale.
|
|
39
40
|
|
|
40
|
-
4. **Never advertise what isn't configured.** "Free shipping over €150" must come from a real shipping rate that is free or carries a `free_over` threshold on a Shipping & Tax Location. Locations are admin-only data, so a storefront cannot read them: the live answer is the cart's `available_shipping_methods` after the address is set, and `shipping-promos.js` normalizes the rules wherever the records *are* in hand. No rule means no banner.
|
|
41
|
+
4. **Never advertise what isn't configured — and never configure what can't be reached.** "Free shipping over €150" must come from a real shipping rate that is free or carries a `free_over` threshold on a Shipping & Tax Location. Locations are admin-only data, so a storefront cannot read them: the live answer is the cart's `available_shipping_methods` after the address is set, and `shipping-promos.js` normalizes the rules wherever the records *are* in hand. No rule means no banner. **Coupons are the same rule in reverse:** codes are admin-only data that a customer can only use by typing them, so a store with coupons needs a code field — `useCart().applyCoupon`, in the cart or, when there's no cart page, in the checkout — rendering `discount_total` and invalid codes inline. No field means no coupons: don't seed them, don't name a code in the copy ([`post-installation.md` §2.3/§2.4](./post-installation.md#23-cart)).
|
|
41
42
|
|
|
42
43
|
All functions return the envelope `{ success, data }` (or `{ success, error, code }`); with the SDK the body is on `res.data`:
|
|
43
44
|
|
|
@@ -56,7 +57,7 @@ Open the matching file under `.agents/skills/commerce/references/` only when a t
|
|
|
56
57
|
| Variant selection | attribute-level selectors, resolving a selection to a variation, availability states, incomplete-selection pricing, add-to-cart contract | [`references/storefront-product-page.md`](./references/storefront-product-page.md) |
|
|
57
58
|
| Reviews | stars on cards and the product page, the review list + submit form (public by email, backend ships complete), the auto-approve toggle, UI-enforced policies (login-gated, verified-only, required rating) | [`references/reviews.md`](./references/reviews.md) |
|
|
58
59
|
| Admin product form | changing the product editor — its stacked sections are **Price & Inventory** (tax group, then the attributes, then a row per variant, or a single *Base price* row when there are none), **Modifiers** (`meta_data`), **Downloads**, **Linked products**; one **Visible** toggle drives `status`. Variants reconcile from the attribute values automatically: no generate step, no per-variant delete. Weight and dimensions are per variant. | [`references/admin-product-form.md`](./references/admin-product-form.md) |
|
|
59
|
-
| Online payments |
|
|
60
|
+
| Online payments | a store that has **opted into card payments** (they are off by default and no provider ships) — the order side (checkout, confirmation, payment links, refund records) *and* the payment webhook are premade; wiring a provider means implementing the four functions in one file (`shared/commerce/card-payment.ts`), and this reference holds the rules plus a complete Stripe implementation | [`references/online-payments.md`](./references/online-payments.md) |
|
|
60
61
|
| Scheduled work | recurring maintenance — stock-hold release, abandoned-cart cleanup, webhook-log pruning, counter-drift repair | [`references/scheduled-work.md`](./references/scheduled-work.md) |
|
|
61
62
|
| Emails | transactional order emails, per-type overrides, deliverability, the email log | [`references/emails.md`](./references/emails.md) |
|
|
62
63
|
| Webhooks | outbound webhooks, HMAC signing, delivery log, auto-disable behavior | [`references/webhooks.md`](./references/webhooks.md) |
|
|
@@ -182,7 +182,7 @@ Actions: `create-link` · `complete-return` · `verify` — the admin side of on
|
|
|
182
182
|
|
|
183
183
|
## commerce/seed-store
|
|
184
184
|
|
|
185
|
-
Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, payment_methods?, with_sample_data?, products?, coupons?, locations? }`. **`payment_methods`** (gateway slugs, e.g. `["card"]`) enables exactly the listed gateways and disables every other gateway row — the one-call way to honor "card-only"/"offline-only"; explicit values win on re-runs, unknown slugs fail as `400 invalid_payload`, and the response reports `payment_methods: { enabled, disabled }` (`null` when not passed). **`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.
|
|
185
|
+
Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, payment_methods?, with_sample_data?, products?, coupons?, locations? }`. **`payment_methods`** (gateway slugs, e.g. `["offline", "card"]`) enables exactly the listed gateways and disables every other gateway row — the one-call way to honor "card-only"/"offline-only"/"both"; explicit values win on re-runs, unknown slugs fail as `400 invalid_payload`, and the response reports `payment_methods: { enabled, disabled }` (`null` when not passed). **Omitted, the seed enables `offline` and leaves `card` disabled** — cards are off by default, so passing `"card"` is what turns them on: only do so with a provider wired in `shared/commerce/card-payment.ts`, or about to be, since an enabled card option with nothing behind it answers `503 no_card_payment_provider` at checkout ([`post-installation.md` §4.1](../post-installation.md#41-off-by-default--enable-only-with-a-provider-wired)). **`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.
|
|
186
186
|
|
|
187
187
|
**`products`** is the one-call catalog bootstrap — the worked example and full semantics are in [`../post-installation.md` §3.1](../post-installation.md#31-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.
|
|
188
188
|
|
|
@@ -27,7 +27,7 @@ The backend is considerably richer than a minimal "grid → cart → pay" shop.
|
|
|
27
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
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
|
-
| **Coupons** | apply/remove by code with full server-side validation (eligibility, limits, per-user usage) | [`apply-coupon`](#commercestorefront-cart) |
|
|
30
|
+
| **Coupons** | apply/remove by code with full server-side validation (eligibility, limits, per-user usage) — codes are admin-only data, so a store with coupons needs a **code field** somewhere in the buy path (cart or checkout) or they can never be redeemed | [`apply-coupon`](#commercestorefront-cart) |
|
|
31
31
|
| **Shipping** | address → matched location → **selectable shipping rates with live costs**, free-over thresholds | [`set-shipping-address`](#commercestorefront-cart) |
|
|
32
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) |
|
|
@@ -36,7 +36,7 @@ The backend is considerably richer than a minimal "grid → cart → pay" shop.
|
|
|
36
36
|
| **Digital products** | entitlement-checked downloads with remaining-count, expiry, and signed URLs for private files | [`get-download`](#commercestorefront-account) |
|
|
37
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 the payment provider integration
|
|
39
|
+
Two things that are **not** in the backend and are yours to build: the visitor UI itself, and — only for a store that opts into card payments, which are off by default with no provider shipped — the payment provider integration: four functions in one file ([`post-installation.md` §4.2](../post-installation.md#42-wiring-a-provider--one-file) for the steps, [`references/online-payments.md`](../references/online-payments.md) for the provider code). Payment methods and the currency are admin-owned data — always render them from `get-store-info` rather than a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
|
|
40
40
|
|
|
41
41
|
## Conventions
|
|
42
42
|
|
|
@@ -74,7 +74,7 @@ Bootstrap data for a storefront. No payload.
|
|
|
74
74
|
```
|
|
75
75
|
`settings` is a safe projection — only display/behavior keys, never admin config.
|
|
76
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`
|
|
77
|
+
`payment_gateways` is **every gateway the admin has enabled** (sorted by their `order`) — the Payments settings switch is the single source of truth, so render this list and never a hardcoded one. The sample above shows both shapes, but a **default-seeded store reports `offline` alone**: cards are off by default and appear only once the gateway is enabled — which should only happen with a provider wired ([`post-installation.md` §4.1](../post-installation.md#41-off-by-default--enable-only-with-a-provider-wired)) — so expect the one-method case in a fresh store. `online: true` marks the card option (`place-order` answers with a payment page to redirect to; `503 no_card_payment_provider` if it was enabled with no provider behind it); everything else settles manually.
|
|
78
78
|
|
|
79
79
|
### `list-products`
|
|
80
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).
|
|
@@ -48,7 +48,7 @@ Confirm your `base44/config.jsonc` `entitiesDir`/`functionsDir` point at these f
|
|
|
48
48
|
4. Check the app's `package.json` for `sonner`, `recharts` and `react-markdown`, and run `npm i` **only** for the ones actually absent — all three ship with the default Base44 template, so the normal outcome is no install at all. Do not re-install a package that is already a dependency. Nothing else is needed. Verify the shadcn primitives listed in [`src/commerce/admin/README.md`](../../src/commerce/admin/README.md) exist in your app.
|
|
49
49
|
5. Mount the admin router (see [`post-installation.md`](./post-installation.md)).
|
|
50
50
|
6. Grant your user the `admin` role.
|
|
51
|
-
7. Seed the store's data — one `commerce/seed-store` call creates the business defaults and, via its `products` payload, the whole catalog (or `with_sample_data: true` for the generic demo; or neither for defaults only). Skipping the call entirely leaves the operator the **Initialize store defaults** first-run screen. See [`post-installation.md`](./post-installation.md) §3; any seed call marks the business as ready and the first-run screen never appears.
|
|
51
|
+
7. Seed the store's data — one `commerce/seed-store` call creates the business defaults and, via its `products` payload, the whole catalog (or `with_sample_data: true` for the generic demo; or neither for defaults only). The defaults include payment methods: the manual **`offline`** option enabled and **`card` switched off**. Cards are optional and not part of installing — enable them only with a payment provider wired, or about to be, or checkout answers `503 no_card_payment_provider` ([`post-installation.md`](./post-installation.md) §4.1). Skipping the call entirely leaves the operator the **Initialize store defaults** first-run screen. See [`post-installation.md`](./post-installation.md) §3; any seed call marks the business as ready and the first-run screen never appears.
|
|
52
52
|
|
|
53
53
|
Check the install at any time:
|
|
54
54
|
|
|
@@ -86,7 +86,7 @@ The template ships an AI copilot for store operators:
|
|
|
86
86
|
|
|
87
87
|
## 4. Building on it — the four storefront requirements
|
|
88
88
|
|
|
89
|
-
If your work includes a customer-facing shopfront, the quick start in [`post-installation.md` §2](./post-installation.md#2-storefront-quick-start--logic-only) already walks all four requirements — you don't need any other reading. In short: attribute-level variant selectors resolved to a `variation_id`; shipping options presented and chosen;
|
|
89
|
+
If your work includes a customer-facing shopfront, the quick start in [`post-installation.md` §2](./post-installation.md#2-storefront-quick-start--logic-only) already walks all four requirements — you don't need any other reading. In short: attribute-level variant selectors resolved to a `variation_id`; shipping options presented and chosen; the payment methods the store actually enables rendered from `get-store-info` (a default store offers `offline` only — cards are off unless enabled with a provider wired, [`post-installation.md`](./post-installation.md) §4.1) with the card redirect handled if they're on, plus an `/order-received` page; and no offers the store isn't configured for. In a React app the shipped `@/commerce/storefront` hooks implement the shipping and order-received requirements outright — build on them rather than re-deriving the flow. The API enforces the first three — a storefront that skips them cannot complete a purchase. (Day-2 deep dives: [`SKILL.md` → *these four are not optional*](./SKILL.md) and [`docs/api-storefront.md`](./docs/api-storefront.md)'s *Required behaviors* table.)
|
|
90
90
|
|
|
91
91
|
## 5. Next steps
|
|
92
92
|
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
# Post-installation
|
|
2
2
|
|
|
3
|
-
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, build the storefront from the quick start below, seed the store's data
|
|
3
|
+
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, build the storefront from the quick start below, and seed the store's data. Online card payments are **optional and off by default** — [§4](#4-payments--optional-cards-off-by-default) has the rule (enable them only with a provider wired) and the wiring; they are never the opening move. Installed into the app at `.agents/skills/commerce/post-installation.md`.
|
|
4
4
|
|
|
5
|
-
> **This file is intentionally the whole job.** Unless the user has a special requirement (a
|
|
5
|
+
> **This file is intentionally the whole job.** Unless the user has a special requirement (wiring a card payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, the storefront — catalog pages, optional cart, checkout, order-received — seeding, and card payments for the stores that need them) is on this page, ending with the [Done when](#5-done-when) checklist. Reading more first just burns time.
|
|
6
|
+
|
|
7
|
+
> **Payments, in short** (full rules in [§4](#4-payments--optional-cards-off-by-default)): **online card payments are optional and off by default.** The seed enables the manual `offline` method, which needs no code or credentials, and leaves the `card` gateway disabled. **Enable cards only if you wire a payment provider — or are about to, in the same stretch of work** (§4.2: one file): an enabled card option with no provider behind it answers `503 no_card_payment_provider` at checkout. Choosing to add cards is fine and often right; just do it **late** — nothing else depends on it, so unless the user brought it up first, asking for a provider key before their store exists only stalls the build.
|
|
6
8
|
|
|
7
9
|
## 0. Sequencing — build the UI while the slow work runs
|
|
8
10
|
|
|
9
|
-
The sections below are in work order — admin mount, storefront, then the data and
|
|
11
|
+
The sections below are in work order — admin mount, storefront, then the data call, and payments last — but the order is not a queue to wait in. The install's wall-clock is dominated by one thing that never needed to block: image generation, the slowest step of the whole install. **Nothing about the storefront waits on live data**: every request/response shape §2 builds against is documented on this page, so the components are written from the docs, and live data only matters once at the end.
|
|
10
12
|
|
|
11
13
|
So interleave — whenever a slow activity is in flight, write UI instead of waiting:
|
|
12
14
|
|
|
13
15
|
1. **Start image generation first** — kick off every product image before anything else (§3.1 "Images"), because it takes the longest and nothing depends on it until seed time.
|
|
14
16
|
2. **Mount the admin router (§1)** — minutes of work — and build the storefront (§2) while the images render.
|
|
15
|
-
3. **The moment the image URLs are back, fire `commerce/seed-store` (§3).** Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either; pick up its response (catalog report, slugs) when you next need it.
|
|
16
|
-
4. **
|
|
17
|
-
5. **
|
|
17
|
+
3. **The moment the image URLs are back, fire `commerce/seed-store` (§3).** Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either; pick up its response (catalog report, slugs) when you next need it. (Payments need nothing from you here: the seed enables the manual `offline` method and leaves cards off — §4.1.)
|
|
18
|
+
4. **Converge**: with the seed done and pages written, open the storefront against the live catalog.
|
|
19
|
+
5. **Only then, card payments (§4) — if the store wants them at all.** Cards are off by default, so an offline-paid store is already done here. If you do enable them, wire the provider in the same stretch of work (§4.1's rule) — raise it, get the provider key, paste the one file, enable the gateway. Payments come last because nothing above depends on them and the ask only makes sense once there is a store to point at; the one exception is a user who named a provider or handed over a key up front, which you can wire whenever it's convenient during the build. Walk the [Done when](#5-done-when) checklist to close.
|
|
18
20
|
|
|
19
|
-
The only real dependency edges are: image URLs → seed payload, and seed done → seeing real products on the finished pages. Everything else overlaps.
|
|
21
|
+
The only real dependency edges are: image URLs → seed payload, and seed done → seeing real products on the finished pages. Everything else overlaps. **Payments have no edge at all** — that is exactly why they go at the end rather than the beginning: the store is fully buildable, demonstrable, payable and reviewable before a single provider credential exists.
|
|
20
22
|
|
|
21
23
|
---
|
|
22
24
|
|
|
@@ -33,7 +35,7 @@ The admin UI is a self-contained React app under `src/commerce/admin/`. Its only
|
|
|
33
35
|
import AdminApp from "@/commerce/admin";
|
|
34
36
|
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
35
37
|
```
|
|
36
|
-
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is one shipped hook — `useOrderReturn` from `@/commerce/storefront` — plus your markup; step 4 of the [storefront quick start](#2-storefront-quick-start--logic-only) below covers it completely.
|
|
38
|
+
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. Build it even for a store that starts offline-only (§4.1): it is one shipped hook, it renders a perfectly good order confirmation for manual orders too, and it is what makes turning cards on later a one-line switch instead of a second project. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is one shipped hook — `useOrderReturn` from `@/commerce/storefront` — plus your markup; step 4 of the [storefront quick start](#2-storefront-quick-start--logic-only) below covers it completely.
|
|
37
39
|
|
|
38
40
|
**Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/store-admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/store-admin" replace />} />`.
|
|
39
41
|
|
|
@@ -60,7 +62,7 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
60
62
|
|
|
61
63
|
## 2. Storefront quick start — logic only
|
|
62
64
|
|
|
63
|
-
No visitor UI ships — and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` — hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3)
|
|
65
|
+
No visitor UI ships — and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` — hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3) runs in parallel with building these pages (§0 has the schedule) — kick off image generation, write the storefront while it renders, seed when the URLs are back. Live data is only needed once, to see real products on the finished pages. **Payments are not a prerequisite for any of this** — the whole buy path down to `place-order` is built and reviewable before a provider exists (a card gateway with no provider simply answers `503 no_card_payment_provider`, and §2.4 shows the graceful fallback), which is why the payment decision comes after these pages work, not before (§4). The four chunks below are the whole happy path; open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
|
|
64
66
|
|
|
65
67
|
**Set up once** — mount the provider above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
|
|
66
68
|
|
|
@@ -175,6 +177,8 @@ const res = await applyCoupon(code); // { ok: true, cart } or { ok: fals
|
|
|
175
177
|
if (!res.ok) setCouponError(res.message); // an invalid code is expected flow — render it inline
|
|
176
178
|
```
|
|
177
179
|
|
|
180
|
+
**If the store has coupons, this is one of the two places a customer can enter one** — a code field plus `applyCoupon`, the discount shown from `cart.totals.discount_total`, applied codes from `cart.coupons` (with `removeCoupon`), and invalid codes rendered inline as above. **Skipping the cart page therefore means the field has to live in checkout (§2.4)** — a seeded coupon nobody can type is a discount the store advertises and cannot honor.
|
|
181
|
+
|
|
178
182
|
Shipping cost on the cart page: a store with exactly **one shipping location** shows its options (and, auto-selected, the cost) even before an address is known — render `cart.available_shipping_methods` and the totals as they come. Several locations report `shipping_status: "missing_address"` until an address resolves one; that address is collected in checkout, where `useCheckout` recalculates shipping and tax automatically the moment it is complete (§2.4) — **don't build a separate estimator or call `set-shipping-address` by hand on the cart page.**
|
|
179
183
|
|
|
180
184
|
**Carry forward:** nothing — the provider keeps the `cart_token`, and the address and method choice live on the shared cart.
|
|
@@ -199,10 +203,24 @@ import { CheckoutProvider, useCheckoutContext, useCart, useStoreInfo,
|
|
|
199
203
|
<ShippingStep />
|
|
200
204
|
<PaymentStep />
|
|
201
205
|
<OrderSummary /> {/* useCart().cart.totals re-renders as the address edits reprice it */}
|
|
206
|
+
<CouponField /> {/* if the store has coupons and no cart page has one — see below */}
|
|
202
207
|
<PlaceOrderButton />
|
|
203
208
|
</CheckoutProvider>
|
|
204
209
|
```
|
|
205
210
|
|
|
211
|
+
**Coupons belong on this page too** — `useCheckout` has no coupon API because it doesn't need one: the cart is shared, so `useCart()` works inside the `CheckoutProvider` tree and a code field is a few lines. **If the store has any coupons and there is no cart page carrying the field (§2.3), the field must be here** — otherwise the codes the store issued can never be redeemed:
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
const { applyCoupon, removeCoupon, cart } = useCart(); // same shared cart the checkout prices
|
|
215
|
+
const res = await applyCoupon(code);
|
|
216
|
+
if (!res.ok) setCouponError(res.message); // invalid/expired/ineligible — expected flow, render inline
|
|
217
|
+
// cart.coupons → [{ code, discount, free_shipping }] with removeCoupon(code)
|
|
218
|
+
// cart.totals.discount_total → show the discount in the summary; every total already accounts for it,
|
|
219
|
+
// and applying one reprices shipping too (free-shipping and coupon-gated rates appear/disappear)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
A coupon that stops validating between here and `place-order` surfaces as `orderError.code === "coupon_invalid"`; the hook re-reads the cart for you, so render the error and let the customer retry.
|
|
223
|
+
|
|
206
224
|
The address form binds inputs to the hook — editing is all it takes to trigger the recalculation (`useStoreInfo().countries` is the country table for the selector):
|
|
207
225
|
|
|
208
226
|
```jsx
|
|
@@ -229,7 +247,7 @@ The two store-data choices — **never hardcode either** — come pre-branched t
|
|
|
229
247
|
)}</PaymentMethodPicker>
|
|
230
248
|
```
|
|
231
249
|
|
|
232
|
-
|
|
250
|
+
A default-seeded store offers **`offline` only** — cards are off unless someone enables them (§4.1) — so `PaymentMethodPicker`'s `single` branch is the common case and your checkout must render it properly. Don't hardcode a Credit card option, and don't wait for a provider to build this step. Where the card gateway *is* enabled with no provider wired, picking it fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods (the fix is §4.2: wire the provider, or switch the gateway back off). `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation.
|
|
233
251
|
|
|
234
252
|
Placing the order — drive the button off the gate; the hook redirects to the provider's payment page when the gateway is online:
|
|
235
253
|
|
|
@@ -268,9 +286,9 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
|
|
|
268
286
|
|
|
269
287
|
## 3. Store data — seeding
|
|
270
288
|
|
|
271
|
-
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. Its writes run in parallel, so the call usually takes a few seconds (large catalogs longer) — and nothing in §2 needs its response anyway, so fire it and keep building (§0). It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card`
|
|
289
|
+
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. Its writes run in parallel, so the call usually takes a few seconds (large catalogs longer) — and nothing in §2 needs its response anyway, so fire it and keep building (§0). It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the two payment gateway rows, `offline` **enabled** and `card` **disabled** (enable cards only with a provider wired — §4.1) — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
|
|
272
290
|
|
|
273
|
-
Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`)
|
|
291
|
+
Pass **`payment_methods`** (gateway slugs, e.g. `["offline", "card"]`) to say exactly how the store gets paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only", "offline-only" or both is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. **Omit it and the store offers `offline` only, with the `card` gateway switched off.** This argument is therefore how cards get turned **on** — and only enable them if a provider is wired or is about to be (§4.1), since an enabled card option with nothing behind it answers `503 no_card_payment_provider` at checkout. Explicit values win on re-runs too, so any of this can be changed later with one more `payment_methods` call. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
|
|
274
292
|
|
|
275
293
|
| Mode | Body | Products created |
|
|
276
294
|
|---|---|---|
|
|
@@ -278,6 +296,8 @@ Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restri
|
|
|
278
296
|
| **Demo data** | `{ store_name, with_sample_data: true }` | The template's 10 generic demo products (skipped if any product exists) |
|
|
279
297
|
| **No products** | `{ store_name }` | None — defaults only |
|
|
280
298
|
|
|
299
|
+
**Seeding `coupons` commits you to a redemption path.** A `commerce.Coupon` is admin-only data — a storefront cannot list codes, so a seeded code is reachable *only* through a field the customer types it into: the cart (§2.3) or, when there's no cart page, the checkout (§2.4). Seed a coupon **only if that field exists** (or you are about to build it); otherwise skip `coupons` entirely, exactly as you would skip advertising a free-shipping threshold no rate backs. The same goes for copy: no "Use WELCOME10 for 10% off" banner without both the coupon record and the field.
|
|
300
|
+
|
|
281
301
|
`with_sample_data` cannot be combined with `products` (**400** `invalid_payload`). Not calling `seed-store` at all leaves the admin's first-run **"Set up your store"** screen for the operator — that screen keys off the `general` settings group, which the seed creates, so don't suppress it in code.
|
|
282
302
|
|
|
283
303
|
**`store_name` is required on a first seed** (**400** `store_name_required`) — pass the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask the user if unsure). It lands in `emails.store_name` and is the email subject/sender name and the public shop name (`get-store-info` → `settings.store_name`). On a re-run it fills a blank name but never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" }`.
|
|
@@ -297,7 +317,9 @@ try {
|
|
|
297
317
|
const res = await base44.functions.invoke("commerce/seed-store", {
|
|
298
318
|
store_name: "Aurora Threads",
|
|
299
319
|
currency: "EUR", // optional — defaults to USD
|
|
300
|
-
payment_methods: ["card"],
|
|
320
|
+
// payment_methods: ["offline", "card"], // omitted = offline only, cards off (the default).
|
|
321
|
+
// // Only pass "card" with a provider wired (§4.1),
|
|
322
|
+
// // or checkout answers 503 no_card_payment_provider.
|
|
301
323
|
products: [
|
|
302
324
|
{ // simple product
|
|
303
325
|
name: "Classic T-Shirt",
|
|
@@ -330,7 +352,9 @@ try {
|
|
|
330
352
|
],
|
|
331
353
|
},
|
|
332
354
|
],
|
|
333
|
-
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
|
|
355
|
+
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional — seed one
|
|
356
|
+
// ONLY if a code field exists in the cart or
|
|
357
|
+
// checkout to redeem it (§2.3 / §2.4)
|
|
334
358
|
locations: [{ // optional — passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
|
|
335
359
|
name: "Israel", countries: ["IL"],
|
|
336
360
|
shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
|
|
@@ -364,7 +388,7 @@ The response reports everything:
|
|
|
364
388
|
},
|
|
365
389
|
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
366
390
|
"currency": { "value": "EUR", "action": "created" }, // "updated" | "unchanged" on re-runs; null when not passed
|
|
367
|
-
"payment_methods": { "enabled": ["card"], "disabled": [
|
|
391
|
+
"payment_methods": { "enabled": ["offline", "card"], "disabled": [] } } // null when not passed (→ offline on, card off)
|
|
368
392
|
```
|
|
369
393
|
|
|
370
394
|
**Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover.
|
|
@@ -373,140 +397,52 @@ A successful response means the data is in — the catalog and settings are live
|
|
|
373
397
|
|
|
374
398
|
---
|
|
375
399
|
|
|
376
|
-
## 4. Payments —
|
|
400
|
+
## 4. Payments — optional; cards off by default
|
|
377
401
|
|
|
378
402
|
The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
|
|
379
403
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
- **The store doesn't take online payments** (cash on delivery, bank transfer, invoicing, pickup, quote-based…) → seed `payment_methods` without `"card"` and skip the rest of this section — the manual gateways are complete as shipped.
|
|
383
|
-
- **The store does take online payments** → this section is the how-to (Stripe below is the reference), but *you* pick the point in the flow. The wiring is self-contained — the file paste and webhook registration touch nothing the storefront depends on, so it **runs in parallel with the storefront build** (§0) rather than before or after it. The one part that involves the user is the provider secret key: ask when it makes sense in the conversation, and keep building while the answer is pending — never stall the install on it. What's non-negotiable is only the end state: don't hand over a finished store with the card gateway enabled but no provider behind it (wire one, or disable the gateway).
|
|
384
|
-
- **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
|
|
404
|
+
### 4.1 Off by default — enable only with a provider wired
|
|
385
405
|
|
|
386
|
-
|
|
406
|
+
**The rule, in full:** online card payments are **off by default** — `commerce/seed-store` enables the manual `offline` method and leaves the `card` gateway disabled. **If you choose to enable cards, make sure a payment provider is wired, or will be in the same stretch of work** (§4.2 — one file): enabled means offered, and an enabled card option with no provider behind it answers `503 no_card_payment_provider` when a customer picks it. Enabling and wiring are the two halves of one step — `payment_methods: ["offline", "card"]` on the seed call (or `["card"]` for card-only, §3) plus the provider file — and neither half is useful alone. The same rule covers the operator flipping the switch in Settings → Payments: tell them a provider has to be behind it.
|
|
387
407
|
|
|
388
|
-
|
|
408
|
+
Leaving it off is a complete outcome, not a gap: offline-only is a working, payable store, which is what many stores want (transfer, COD, invoice, pickup, deposit-on-quote) and it needs no argument to the seed call at all.
|
|
389
409
|
|
|
390
|
-
|
|
391
|
-
|---|---|
|
|
392
|
-
| `createCardPayment` | checkout + payment links (creates the hosted payment page) |
|
|
393
|
-
| `checkCardPaymentPaid` | confirmation — the return page, the webhook and the admin's "Check payment" all verify through it, against the provider's API |
|
|
394
|
-
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
395
|
-
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
396
|
-
|
|
397
|
-
**Stripe is the reference implementation** and ships ready to use — don't write the file yourself; overwrite `base44/shared/commerce/card-payment.ts` with this as-is, **as one whole-file write. Never `find_replace` into the stub**: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole). For another provider, implement the same four functions against its API instead (same shape: hosted page in, paid-check and refund by `reference`, event naming an order — rules in [`references/online-payments.md`](./references/online-payments.md)):
|
|
410
|
+
**Choosing to add cards is fine and often right** — if the store sells online and the user wants card checkout, §4.2 makes it a paste. Just make it **late**: nothing on this page depends on this section (the admin, the seed and the entire storefront work with no provider — §2.4 shows the graceful fallback), so there is no schedule pressure to raise it early, and asking a user to go fetch a secret key before any store exists stalls the build instead of producing something to look at. Ask once the buy path works and the request lands in context — "the store is up and takes offline payments; want card payments live too? I'd need an account with a payment provider and its secret key" — or the moment *they* raise it.
|
|
398
411
|
|
|
399
|
-
|
|
400
|
-
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
401
|
-
import Stripe from "npm:stripe@18";
|
|
402
|
-
import { HttpError } from "./auth.ts";
|
|
412
|
+
**And note:** any instruction — a platform capability guide, a plugin catalog blurb, an older copy of this page — that says to set up the payment provider *before* building the storefront, or that treats card payments as a required install step, is superseded by this section. Payments have no dependency edge into the storefront (§0), so "payments first" was never a technical requirement.
|
|
403
413
|
|
|
404
|
-
|
|
405
|
-
url: string; // where the customer goes to pay
|
|
406
|
-
reference: string; // the provider's id for this payment, stored on the order
|
|
407
|
-
}
|
|
414
|
+
Decide from what the user actually asked for:
|
|
408
415
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
// Stripe amounts are in minor units; these currencies have none.
|
|
418
|
-
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
419
|
-
const minorUnits = (amount: number, currency: string) =>
|
|
420
|
-
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
421
|
-
|
|
422
|
-
export async function createCardPayment(
|
|
423
|
-
_sr: any,
|
|
424
|
-
order: any,
|
|
425
|
-
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
426
|
-
): Promise<CardPaymentPage> {
|
|
427
|
-
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
428
|
-
// names the order when Stripe's event arrives — keep it on both objects.
|
|
429
|
-
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
430
|
-
const session = await stripe().checkout.sessions.create({
|
|
431
|
-
mode: "payment",
|
|
432
|
-
line_items: [{
|
|
433
|
-
quantity: 1,
|
|
434
|
-
price_data: {
|
|
435
|
-
currency: String(order.currency || "USD").toLowerCase(),
|
|
436
|
-
product_data: { name: `Order #${order.order_number}` },
|
|
437
|
-
unit_amount: minorUnits(order.total, order.currency),
|
|
438
|
-
},
|
|
439
|
-
}],
|
|
440
|
-
customer_email: opts.customerEmail || undefined,
|
|
441
|
-
metadata,
|
|
442
|
-
payment_intent_data: { metadata },
|
|
443
|
-
success_url: opts.successUrl,
|
|
444
|
-
cancel_url: opts.cancelUrl,
|
|
445
|
-
});
|
|
446
|
-
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
447
|
-
return { url: session.url, reference: session.id };
|
|
448
|
-
}
|
|
416
|
+
| What the request says | Decision | When to act |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| A provider is named, or a key is handed over ("connect Stripe", a secret key in the prompt) | Wire it (§4.2) and enable the gateway | Whenever convenient during the build; it blocks nothing, so it never has to come first either |
|
|
419
|
+
| Selling online is implied but no provider named (an ordinary web shop; "products, a cart and a checkout"; "customers pay by card") | Cards are a reasonable read — but it's an inference about **what** to offer, never a licence to move **when**: ask for the provider and its key, then wire + enable | **After** the admin, storefront and catalog work — the ask is the closing step, not the opening one |
|
|
420
|
+
| The store is paid another way (bank transfer, COD, invoice, pickup, quotes, deposits) | Nothing to do — the default already is exactly this. Don't wire a provider, don't pass `payment_methods` | — |
|
|
421
|
+
| Payment isn't mentioned anywhere in the request | Leave the offline default in place, finish everything else, and **mention it at handover**: the store takes offline payments; cards are one step away if they want them | At the end, as a closing note |
|
|
449
422
|
|
|
450
|
-
|
|
451
|
-
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
452
|
-
// The payment must be for THIS order — stops a reference to some other
|
|
453
|
-
// (paid) session being replayed against a different order.
|
|
454
|
-
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
455
|
-
}
|
|
423
|
+
Whichever row applies, **say where payments landed** in your summary to the user — including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
456
424
|
|
|
457
|
-
|
|
458
|
-
reference: string; amount: number; currency: string; reason?: string;
|
|
459
|
-
}): Promise<{ refund_id: string }> {
|
|
460
|
-
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
461
|
-
if (!session.payment_intent) {
|
|
462
|
-
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
463
|
-
}
|
|
464
|
-
const refund = await stripe().refunds.create({
|
|
465
|
-
payment_intent: String(session.payment_intent),
|
|
466
|
-
amount: minorUnits(opts.amount, opts.currency),
|
|
467
|
-
});
|
|
468
|
-
return { refund_id: refund.id };
|
|
469
|
-
}
|
|
425
|
+
Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point. Never edit `commerce.PaymentGateway` records yourself.
|
|
470
426
|
|
|
471
|
-
|
|
472
|
-
export interface CardWebhookEvent {
|
|
473
|
-
order_id: string;
|
|
474
|
-
order_key: string;
|
|
475
|
-
paid: boolean; // true only after signature verification — never from a raw body
|
|
476
|
-
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
477
|
-
}
|
|
427
|
+
### 4.2 Wiring a provider — one file
|
|
478
428
|
|
|
479
|
-
|
|
480
|
-
* Stripe webhook events. The event body is never trusted: this only names
|
|
481
|
-
* the order (from the metadata createCardPayment attached) and returns
|
|
482
|
-
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
483
|
-
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
484
|
-
* signing secret is needed. (Optional fast path that skips the API
|
|
485
|
-
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
486
|
-
*/
|
|
487
|
-
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
488
|
-
let event: any;
|
|
489
|
-
try { event = JSON.parse(payload); } catch { return null; }
|
|
490
|
-
const metadata = event?.data?.object?.metadata;
|
|
491
|
-
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
492
|
-
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
493
|
-
}
|
|
494
|
-
```
|
|
429
|
+
*Only if §4.1 said cards.* The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. The card gateway ships disabled, so an unimplemented file is normally invisible to customers — but a gateway enabled without it answers `503 no_card_payment_provider` at checkout, which is why this file and the `payment_methods` switch always go together.
|
|
495
430
|
|
|
496
|
-
|
|
431
|
+
| Function | Backs |
|
|
432
|
+
|---|---|
|
|
433
|
+
| `createCardPayment` | checkout + payment links (creates the hosted payment page) |
|
|
434
|
+
| `checkCardPaymentPaid` | confirmation — the return page, the webhook and the admin's "Check payment" all verify through it, against the provider's API |
|
|
435
|
+
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
436
|
+
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
497
437
|
|
|
498
|
-
|
|
499
|
-
2. **Webhook endpoint** — so orders are confirmed even when the buyer pays and closes the tab: register `https://<app-domain>/functions/commerce/payment-webhook` with Stripe for the `checkout.session.completed` event. There is **no signing secret to store** — events are treated as nudges and verified against Stripe's API. Registration is one call with the same secret key (or the user can do it in the Stripe dashboard):
|
|
438
|
+
Four steps, whichever provider it is:
|
|
500
439
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
506
|
-
});
|
|
507
|
-
```
|
|
440
|
+
1. **Implement the four functions** in `base44/shared/commerce/card-payment.ts` against the provider's API. **No provider ships with the template** — the file arrives as stubs, and [`references/online-payments.md`](./references/online-payments.md) holds the per-provider rules plus a complete **Stripe** implementation to paste if Stripe is the provider the store chose. Write the file **whole, in one write. Never `find_replace` into the stubs**: a partial patch leaves the originals behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole).
|
|
441
|
+
2. **Store the provider's API credential as a backend app secret** (read with `Deno.env.get(...)` — never in code, never in an entity). Test credentials work end to end. This is the only step that needs the user, so it sets the timing of the whole section (§4.1): ask when the store is standing, not in the install's first message — and if the answer takes a while, keep the rest of the work moving rather than idling on it.
|
|
442
|
+
3. **Register the premade webhook URL** — `https://<app-domain>/functions/commerce/payment-webhook` — with the provider, for its "payment succeeded" event, so orders are confirmed even when the buyer pays and closes the tab. There is **no signing secret to store**: events are treated as nudges and verified against the provider's API. (Per-provider registration calls are in the reference; the user can also do it in the provider's dashboard.)
|
|
443
|
+
4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. Call `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }` (or `["card"]` for card-only) — safe on a seeded store: no catalog needed, products are skipped, and it converges every gateway row to that set. Skipping this step is the usual reason a freshly wired provider "doesn't show up at checkout"; doing it *without* steps 1–2 is what produces `503 no_card_payment_provider`.
|
|
508
444
|
|
|
509
|
-
That's it — checkout redirect, `/order-received` confirmation, the webhook, the admin's "Check payment" button, payment links and provider refunds all run through this one file.
|
|
445
|
+
That's it — checkout redirect, `/order-received` confirmation, the webhook, the admin's "Check payment" button, payment links and provider refunds all run through this one file.
|
|
510
446
|
|
|
511
447
|
---
|
|
512
448
|
|
|
@@ -515,9 +451,11 @@ That's it — checkout redirect, `/order-received` confirmation, the webhook, th
|
|
|
515
451
|
Post-installation is complete when every line below holds. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
|
|
516
452
|
|
|
517
453
|
- [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
|
|
518
|
-
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the
|
|
454
|
+
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; `payment_methods` passed only if the store's methods differ from the default (offline on, cards off).
|
|
519
455
|
- [ ] **Catalog pages and a checkout exist** — the catalog UI in whatever form fits the store (a product list, product pages, or both — one can be enough), a cart step only if the store wants one (§2.3), and the checkout built on `useCheckout` with `/order-received` rendering `useOrderReturn` (§2.4).
|
|
456
|
+
- [ ] **Every coupon the store has can actually be redeemed** — if `coupons` was seeded (or the operator has codes), a code field with `applyCoupon` exists in the cart or the checkout, showing `discount_total` and rendering invalid codes inline (§2.3/§2.4). No field → don't seed coupons and don't mention codes in the copy.
|
|
520
457
|
- [ ] **One `<StorefrontProvider>`** above every storefront route — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
|
|
458
|
+
- [ ] **Cards are either off, or on with a provider behind them** (§4.1) — the default (offline enabled, `card` off) is a complete state and needs nothing. If you enabled cards, the provider must be wired: `card-payment.ts` written whole, secret stored, webhook registered. Enabled without a wired provider means `503 no_card_payment_provider` at checkout; wired without enabling means customers never see the option. Say where payments landed when you hand over.
|
|
521
459
|
|
|
522
460
|
## 6. Next
|
|
523
461
|
|
|
@@ -4,6 +4,8 @@ The store ships with a **Credit card** checkout option (`commerce.PaymentGateway
|
|
|
4
4
|
|
|
5
5
|
What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal, Adyen, a local PSP…) means implementing **one file** — `base44/shared/commerce/card-payment.ts`, four functions — and nothing else: no entity is touched, no caller or UI needs editing, and the payment webhook *function* is premade (it calls this file's `parseWebhook`).
|
|
6
6
|
|
|
7
|
+
**Does this store even need a provider?** Card payments are **optional and off by default**: `commerce/seed-store` enables the manual **`offline`** method (bank transfer, cash on delivery, pickup — no code at all) and leaves the **`card`** gateway disabled. **Enable cards only if this file is implemented, or is about to be in the same stretch of work** — an enabled card option with no provider behind it answers `503 no_card_payment_provider` at checkout, so the gateway switch (seed `payment_methods`) and this file always move together. [`post-installation.md` §4.1](../post-installation.md#41-off-by-default--enable-only-with-a-provider-wired) has the rule and the timing (short version: a fine thing to add, but at the end of a build, not the start — nothing depends on it).
|
|
8
|
+
|
|
7
9
|
| Function | Backs |
|
|
8
10
|
|---|---|
|
|
9
11
|
| `createCardPayment` | checkout + payment links: make a hosted payment page for `order.total`, return `{ url, reference }`, and attach `order.id`/`order.order_key` to the payment's metadata |
|
|
@@ -11,9 +13,9 @@ What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal
|
|
|
11
13
|
| `refundCardPayment` | admin refunds through the provider (optional — leave the stub to keep refunds manual) |
|
|
12
14
|
| `parseWebhook` | webhook validation: name the order a provider event is about (from the echoed metadata), and vouch `paid: true` **only** after verifying the request signature over the raw body bytes |
|
|
13
15
|
|
|
14
|
-
**Wiring
|
|
16
|
+
**Wiring a payment provider?** Whichever one the store chose, the job is the same: implement those four functions against its API, following the rules on this page. As a worked example, [a complete Stripe implementation](#reference-implementation--stripe) sits at the bottom of the page — with its secret and webhook-endpoint steps — usable as-is if Stripe happens to be the provider, and as a model of the four contracts if it isn't. It is a reference for one common choice, **not a bundled provider**: no provider ships with the template.
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
The card option ships **disabled**, so on a default store none of this is customer-visible. Where the gateway has been enabled without the file, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods) — implement the file, or switch the option back off in Settings → Payments. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions, and the operator moves it on once the money arrives — those need no code at all, and the admin can add more of them in Settings → Payments.
|
|
17
19
|
|
|
18
20
|
## How the premade flow works
|
|
19
21
|
|
|
@@ -28,7 +30,7 @@ Until the file is implemented, picking Credit card at checkout answers `503 no_c
|
|
|
28
30
|
|
|
29
31
|
- **Replace `card-payment.ts` whole** — one write of the full new file, never a `find_replace` into the shipped stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"); the fix is always to rewrite the file whole.
|
|
30
32
|
- **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
|
|
31
|
-
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off.
|
|
33
|
+
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["offline", "card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off. Omitting it means offline-only, cards off — so **enabling the card gateway is part of wiring a provider**, not a separate concern: a correct `card-payment.ts` is invisible at checkout until that call runs, and that call without the file gives customers a `503`.
|
|
32
34
|
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. It should also check the payment **names this order** (compare the payment's metadata `order_id` to `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
33
35
|
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` + `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
34
36
|
- **`parseWebhook` never trusts a raw body.** Return `paid: false` (the verify-via-API nudge) unless you verified the provider's signature over the raw payload bytes; set `reference` only from a verified event, otherwise leave it unset so the premade flow uses the reference stored on the order at checkout.
|
|
@@ -39,12 +41,130 @@ Until the file is implemented, picking Credit card at checkout answers `503 no_c
|
|
|
39
41
|
|
|
40
42
|
`parseWebhook(req, payload)` lives in `card-payment.ts` with the other three; `commerce/payment-webhook` (the function file) is premade and calls it with the raw request and the raw body — the exact bytes, so signature schemes work. It returns `CardWebhookEvent | null`:
|
|
41
43
|
|
|
42
|
-
- **The simple, secure default — the nudge**: parse the event, read the `order_id`/`order_key` metadata `createCardPayment` attached, return `{ order_id, order_key, paid: false }`. No signing secret; the premade flow verifies via `checkCardPaymentPaid` against the provider's API, so forgery is impossible by construction. This is what the Stripe
|
|
44
|
+
- **The simple, secure default — the nudge**: parse the event, read the `order_id`/`order_key` metadata `createCardPayment` attached, return `{ order_id, order_key, paid: false }`. No signing secret; the premade flow verifies via `checkCardPaymentPaid` against the provider's API, so forgery is impossible by construction. This is what the Stripe implementation below does.
|
|
43
45
|
- **The signature-verified fast path** (optional): verify the provider's signature over the raw `payload` bytes (Stripe: `constructEventAsync` with a webhook signing secret) and return `paid: true` with the event's `reference` for a verified successful payment; the premade code then trusts it without the API round-trip. `paid: true` from an unverified body is the one way to break this design — never do it.
|
|
44
46
|
- Return **`null`** for events that aren't about a payment for one of this store's orders; the function answers 200 so the provider doesn't retry.
|
|
45
47
|
|
|
46
48
|
Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
|
|
47
49
|
|
|
50
|
+
## Reference implementation — Stripe
|
|
51
|
+
|
|
52
|
+
**A worked example, not a bundled provider**: the template ships with no provider at all, and Stripe is one option among many (PayPal, Adyen, a local PSP — the four functions are the same shape against any API). Paste this in when Stripe is the provider the store actually chose; read it as a model of the four contracts when the provider is something else. Either way, only once §4.1's rule says cards are wanted at all.
|
|
53
|
+
|
|
54
|
+
Overwrite `base44/shared/commerce/card-payment.ts` with this **as one whole-file write** (never `find_replace` into the stubs — see *Implementation rules* above):
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
58
|
+
import Stripe from "npm:stripe@18";
|
|
59
|
+
import { HttpError } from "./auth.ts";
|
|
60
|
+
|
|
61
|
+
export interface CardPaymentPage {
|
|
62
|
+
url: string; // where the customer goes to pay
|
|
63
|
+
reference: string; // the provider's id for this payment, stored on the order
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stripe = () => {
|
|
67
|
+
const key = Deno.env.get("STRIPE_SECRET_KEY");
|
|
68
|
+
if (!key) {
|
|
69
|
+
throw new HttpError(503, "Card payments are not configured — the STRIPE_SECRET_KEY secret is missing.", "no_card_payment_provider");
|
|
70
|
+
}
|
|
71
|
+
return new Stripe(key);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Stripe amounts are in minor units; these currencies have none.
|
|
75
|
+
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
76
|
+
const minorUnits = (amount: number, currency: string) =>
|
|
77
|
+
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
78
|
+
|
|
79
|
+
export async function createCardPayment(
|
|
80
|
+
_sr: any,
|
|
81
|
+
order: any,
|
|
82
|
+
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
83
|
+
): Promise<CardPaymentPage> {
|
|
84
|
+
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
85
|
+
// names the order when Stripe's event arrives — keep it on both objects.
|
|
86
|
+
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
87
|
+
const session = await stripe().checkout.sessions.create({
|
|
88
|
+
mode: "payment",
|
|
89
|
+
line_items: [{
|
|
90
|
+
quantity: 1,
|
|
91
|
+
price_data: {
|
|
92
|
+
currency: String(order.currency || "USD").toLowerCase(),
|
|
93
|
+
product_data: { name: `Order #${order.order_number}` },
|
|
94
|
+
unit_amount: minorUnits(order.total, order.currency),
|
|
95
|
+
},
|
|
96
|
+
}],
|
|
97
|
+
customer_email: opts.customerEmail || undefined,
|
|
98
|
+
metadata,
|
|
99
|
+
payment_intent_data: { metadata },
|
|
100
|
+
success_url: opts.successUrl,
|
|
101
|
+
cancel_url: opts.cancelUrl,
|
|
102
|
+
});
|
|
103
|
+
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
104
|
+
return { url: session.url, reference: session.id };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
108
|
+
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
109
|
+
// The payment must be for THIS order — stops a reference to some other
|
|
110
|
+
// (paid) session being replayed against a different order.
|
|
111
|
+
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
115
|
+
reference: string; amount: number; currency: string; reason?: string;
|
|
116
|
+
}): Promise<{ refund_id: string }> {
|
|
117
|
+
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
118
|
+
if (!session.payment_intent) {
|
|
119
|
+
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
120
|
+
}
|
|
121
|
+
const refund = await stripe().refunds.create({
|
|
122
|
+
payment_intent: String(session.payment_intent),
|
|
123
|
+
amount: minorUnits(opts.amount, opts.currency),
|
|
124
|
+
});
|
|
125
|
+
return { refund_id: refund.id };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
129
|
+
export interface CardWebhookEvent {
|
|
130
|
+
order_id: string;
|
|
131
|
+
order_key: string;
|
|
132
|
+
paid: boolean; // true only after signature verification — never from a raw body
|
|
133
|
+
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Stripe webhook events. The event body is never trusted: this only names
|
|
138
|
+
* the order (from the metadata createCardPayment attached) and returns
|
|
139
|
+
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
140
|
+
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
141
|
+
* signing secret is needed. (Optional fast path that skips the API
|
|
142
|
+
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
143
|
+
*/
|
|
144
|
+
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
145
|
+
let event: any;
|
|
146
|
+
try { event = JSON.parse(payload); } catch { return null; }
|
|
147
|
+
const metadata = event?.data?.object?.metadata;
|
|
148
|
+
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
149
|
+
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Then the credential and the webhook endpoint:
|
|
154
|
+
|
|
155
|
+
1. **Secret**: the user's Stripe **secret key**, stored as the `STRIPE_SECRET_KEY` app secret (backend env — never in code, never in an entity). Test keys (`sk_test_…`) work end to end.
|
|
156
|
+
2. **Webhook endpoint**: register `https://<app-domain>/functions/commerce/payment-webhook` with Stripe for `checkout.session.completed`. There is **no signing secret to store** — events are nudges, verified against Stripe's API. One call with the same secret key (or the user does it in the Stripe dashboard):
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
await fetch("https://api.stripe.com/v1/webhook_endpoints", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
162
|
+
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Finally **enable the `card` gateway** — it is seeded off, so none of the above is visible at checkout until `commerce/seed-store` runs with `{ payment_methods: ["offline", "card"] }` ([`post-installation.md` §4.2](../post-installation.md#42-wiring-a-provider--one-file)).
|
|
167
|
+
|
|
48
168
|
## Storefront requirements (unchanged by any of this)
|
|
49
169
|
|
|
50
170
|
- Redirect to `payment.checkout_url` when `place-order` returns `payment.status === "requires_payment"`.
|
|
@@ -205,13 +205,15 @@ export default function PaymentsSettings() {
|
|
|
205
205
|
</div>
|
|
206
206
|
|
|
207
207
|
{/* The "card" option redirects to a provider-hosted payment page.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
208
|
+
It ships disabled: no provider comes with the template, and
|
|
209
|
+
wiring one means implementing the four functions in
|
|
210
|
+
shared/commerce/card-payment.ts (rules, and a Stripe
|
|
211
|
+
implementation to paste, in
|
|
212
|
+
.agents/skills/commerce/references/online-payments.md; the
|
|
213
|
+
payment webhook is premade). Enabled without that, picking it
|
|
214
|
+
at checkout answers 503 no_card_payment_provider.
|
|
215
|
+
Deliberately not shown to the store operator — it's developer
|
|
216
|
+
guidance, not store configuration. */}
|
|
215
217
|
</div>
|
|
216
218
|
))}
|
|
217
219
|
</CardContent>
|