@base44/app-plugin-commerce 0.1.17 → 0.1.19
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 +2 -2
- 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 +3 -3
- package/skills/commerce/docs/api-admin.md +2 -1
- package/skills/commerce/docs/api-storefront.md +15 -1
- package/skills/commerce/post-installation.md +51 -125
- package/skills/commerce/references/online-payments.md +120 -2
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +9 -7
- package/src/commerce/storefront/StorefrontProvider.jsx +7 -3
- package/src/commerce/storefront/index.js +2 -1
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).
|
|
@@ -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 — 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` (Stripe
|
|
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
|
|
@@ -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.19",
|
|
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
|
@@ -15,7 +15,7 @@ Operational guidance for extending, operating and building on the Base44 Commerc
|
|
|
15
15
|
|
|
16
16
|
## IMPORTANT — first-time installation
|
|
17
17
|
|
|
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;
|
|
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).
|
|
19
19
|
|
|
20
20
|
## Working on the UI
|
|
21
21
|
|
|
@@ -36,7 +36,7 @@ Agents keep shipping storefronts that miss these, and each one breaks buying out
|
|
|
36
36
|
|
|
37
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.)
|
|
38
38
|
|
|
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 (
|
|
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.
|
|
40
40
|
|
|
41
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)).
|
|
42
42
|
|
|
@@ -57,7 +57,7 @@ Open the matching file under `.agents/skills/commerce/references/` only when a t
|
|
|
57
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) |
|
|
58
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) |
|
|
59
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) |
|
|
60
|
-
| 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) |
|
|
61
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) |
|
|
62
62
|
| Emails | transactional order emails, per-type overrides, deliverability, the email log | [`references/emails.md`](./references/emails.md) |
|
|
63
63
|
| Webhooks | outbound webhooks, HMAC signing, delivery log, auto-disable behavior | [`references/webhooks.md`](./references/webhooks.md) |
|
|
@@ -16,7 +16,8 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
|
|
|
16
16
|
| commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
|
|
17
17
|
| commerce.ProductCategory, commerce.ProductRibbon | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; ribbon get-or-create by name |
|
|
18
18
|
| commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
|
|
19
|
-
| commerce.ShippingTaxLocation
|
|
19
|
+
| commerce.ShippingTaxLocation | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
|
|
20
|
+
| commerce.PaymentGateway | direct | **`commerce/seed-store`** `payment_methods` to switch methods on/off; **direct CRUD** to change a record (add or rename a manual option, its `description`, bank accounts, `order`) | one seed call converges every gateway row to the given set, so "enable cards", "card-only" and "offline-only" are one idempotent call; the record's own fields are ordinary config — the admin's Settings → Payments screen edits them directly |
|
|
20
21
|
| commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
|
|
21
22
|
| commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
|
|
22
23
|
| commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
|
|
@@ -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
|
|
|
@@ -89,6 +89,20 @@ Each row is the product record (minus paywalled fields) **plus a resolved `ribbo
|
|
|
89
89
|
|
|
90
90
|
**Filters stack.** `category_id`, `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`/`max_price`, `on_sale`, `featured` and `in_stock_only` are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
|
|
91
91
|
|
|
92
|
+
**The same call also serves a short strip anywhere else** — a homepage hero or banner, a "featured" row, a "new in" rail, related picks beside an article. `per_page` bounds the result and the filters/sort choose what lands in it, so a small curated slice is one request; there is no separate endpoint, and no reason to fetch a big page and slice it client-side. Available if a page wants one:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
|
|
96
|
+
const cat = (payload) => inv("commerce/storefront-catalog", { action: "list-products", ...payload });
|
|
97
|
+
|
|
98
|
+
const { products: featured } = await cat({ featured: true, per_page: 4 });
|
|
99
|
+
const { products: newest } = await cat({ sort: "-created_date", per_page: 6 });
|
|
100
|
+
const { products: deals } = await cat({ on_sale: true, in_stock_only: true, per_page: 4 });
|
|
101
|
+
const { products: topInCat } = await cat({ category_id, sort: "popularity", per_page: 4 });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`featured` is the admin's own per-product flag (the star in the products list, *Featured product* in the editor's Publish box; `commerce/seed-store` accepts `featured: true` per product), so a featured strip is curated store data rather than slugs pinned in the UI. Rows are the ordinary listing rows — same fields a grid card renders ([`../references/product-render.md`](../references/product-render.md)). **A filter may legitimately match nothing** (no product starred, nothing discounted), so drive the block off the returned array's length — hide it or fall back to another slice — instead of assuming rows came back; `has_next` tells you whether a "see all" link has more behind it.
|
|
105
|
+
|
|
92
106
|
### `get-product`
|
|
93
107
|
**Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
|
|
94
108
|
|
|
@@ -1,10 +1,10 @@
|
|
|
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, and seed the store's data. Online card payments are **optional and off by default** — [§4](#4-payments--optional-
|
|
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
6
|
|
|
7
|
-
> **Payments, in short** (full rules in [§4](#4-payments--optional-
|
|
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.
|
|
8
8
|
|
|
9
9
|
## 0. Sequencing — build the UI while the slow work runs
|
|
10
10
|
|
|
@@ -64,17 +64,35 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
64
64
|
|
|
65
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.
|
|
66
66
|
|
|
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:
|
|
67
|
+
**Set up once** — mount the provider **around `<Routes>`**, so it sits 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:
|
|
68
68
|
|
|
69
69
|
```jsx
|
|
70
70
|
import { StorefrontProvider } from "@/commerce/storefront";
|
|
71
71
|
import { base44 } from "@/api/base44Client";
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
import AdminApp from "@/commerce/admin";
|
|
73
|
+
|
|
74
|
+
<BrowserRouter> {/* the app's existing router, wherever it lives */}
|
|
75
|
+
<StorefrontProvider base44={base44}> {/* wraps the router's <Routes>, not a child of it */}
|
|
76
|
+
<Routes> {/* ONE <Routes> — merge storefront routes into the app's */}
|
|
77
|
+
<Route path="/" element={<Home />} />
|
|
78
|
+
<Route path="/product/:slug" element={<ProductPage />} />
|
|
79
|
+
<Route path="/checkout" element={<Checkout />} />
|
|
80
|
+
<Route path="/order-received" element={<OrderReceived />} />
|
|
81
|
+
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
82
|
+
</Routes>
|
|
83
|
+
</StorefrontProvider>
|
|
84
|
+
</BrowserRouter>
|
|
76
85
|
```
|
|
77
86
|
|
|
87
|
+
> ⚠ **`<Routes>` accepts only `<Route>` children — the provider goes outside it.** Nesting it inside (the natural reading of "wrap the storefront routes", and the usual mistake) throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. Wrapping the whole `<Routes>` as above is the simplest correct shape and fine even with the admin route inside — the admin doesn't use the storefront hooks. Same rule per file: an app that already has a `<Routes>` gets the new pages **added to it**, never a second `<Routes>` block. To scope the provider to storefront routes only, use a pathless **layout route** — the one place a wrapper component is legal — and render `<Outlet />` inside it:
|
|
88
|
+
> ```jsx
|
|
89
|
+
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
90
|
+
> <Route path="/" element={<Home />} /> {/* these share one cart */}
|
|
91
|
+
> <Route path="/checkout" element={<Checkout />} />
|
|
92
|
+
> </Route>
|
|
93
|
+
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
94
|
+
> ```
|
|
95
|
+
|
|
78
96
|
The provider owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either — and never mount a second provider or create a second client**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). Inside the tree, `useStorefront()` returns the shared client for catalog calls, and `store.inv(fn, payload)` on it is the raw escape hatch (it unwraps the `{ success, data }` envelope). If non-React code also needs the client, create it once in `src/lib/storefront.js` (`createStorefront(base44)` from `@/commerce/utils`) and pass that same instance via `<StorefrontProvider store={store}>`.
|
|
79
97
|
|
|
80
98
|
**What each hook / client method resolves to** — every envelope is already unwrapped, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
|
|
@@ -111,6 +129,19 @@ const { products, page, per_page, has_next } = await store.listProducts({
|
|
|
111
129
|
|
|
112
130
|
Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
|
|
113
131
|
|
|
132
|
+
**A short strip of products is the same call with a small `per_page`.** Products are not confined to the grid — a homepage hero or banner, a "featured" row, a "new in" rail, a few picks beside an article are all one `listProducts` call: `per_page` bounds how many come back and the filters/sort decide which ones. No separate endpoint, and never fetch a big page to slice client-side. Use this if a page of yours wants such a strip; the catalog pages above are complete without one:
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const { products: featured } = await store.listProducts({ featured: true, per_page: 4 });
|
|
136
|
+
const { products: newest } = await store.listProducts({ sort: "-created_date", per_page: 6 });
|
|
137
|
+
const { products: deals } = await store.listProducts({ on_sale: true, per_page: 4 });
|
|
138
|
+
const { products: topInCat } = await store.listProducts({ category_id, sort: "popularity", per_page: 4 });
|
|
139
|
+
// filters stack (featured + category_id + in_stock_only …); sort: -created_date | name |
|
|
140
|
+
// price | -price | popularity | rating. Rows are the same shape the cards above render.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`featured` is the merchant's own flag — the star in the admin's product list and *Featured product* in the editor's Publish box — and `seed-store` accepts `featured: true` per product, so such a row stays curated store data instead of a hardcoded list of slugs. **Any filter can legitimately match nothing** (nobody has starred a product, nothing is discounted): render the block from the returned array's length — hide it, or fall back to another slice — rather than assuming rows came back.
|
|
144
|
+
|
|
114
145
|
**Carry forward:** each card links to the product page by **`slug`**.
|
|
115
146
|
|
|
116
147
|
### 2.2 Product page — variant selection included
|
|
@@ -397,7 +428,7 @@ A successful response means the data is in — the catalog and settings are live
|
|
|
397
428
|
|
|
398
429
|
---
|
|
399
430
|
|
|
400
|
-
## 4. Payments — optional
|
|
431
|
+
## 4. Payments — optional; cards off by default
|
|
401
432
|
|
|
402
433
|
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.
|
|
403
434
|
|
|
@@ -407,7 +438,7 @@ The order side of payments is **already implemented** (checkout routing, confirm
|
|
|
407
438
|
|
|
408
439
|
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.
|
|
409
440
|
|
|
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
|
|
441
|
+
**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.
|
|
411
442
|
|
|
412
443
|
**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.
|
|
413
444
|
|
|
@@ -415,14 +446,14 @@ Decide from what the user actually asked for:
|
|
|
415
446
|
|
|
416
447
|
| What the request says | Decision | When to act |
|
|
417
448
|
|---|---|---|
|
|
418
|
-
| A provider is named, or a key is handed over ("connect Stripe",
|
|
449
|
+
| 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
450
|
| 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
451
|
| 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
452
|
| 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 |
|
|
422
453
|
|
|
423
454
|
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.
|
|
424
455
|
|
|
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.
|
|
456
|
+
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. That call is the on/off switch; editing a `commerce.PaymentGateway` record directly is for the record's own fields (a new or renamed manual option, its description, bank accounts, ordering).
|
|
426
457
|
|
|
427
458
|
### 4.2 Wiring a provider — one file
|
|
428
459
|
|
|
@@ -435,121 +466,16 @@ Changing the answer later is one more seed call, not surgery: `commerce/seed-sto
|
|
|
435
466
|
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
436
467
|
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
437
468
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
```ts
|
|
441
|
-
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
442
|
-
import Stripe from "npm:stripe@18";
|
|
443
|
-
import { HttpError } from "./auth.ts";
|
|
444
|
-
|
|
445
|
-
export interface CardPaymentPage {
|
|
446
|
-
url: string; // where the customer goes to pay
|
|
447
|
-
reference: string; // the provider's id for this payment, stored on the order
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const stripe = () => {
|
|
451
|
-
const key = Deno.env.get("STRIPE_SECRET_KEY");
|
|
452
|
-
if (!key) {
|
|
453
|
-
throw new HttpError(503, "Card payments are not configured — the STRIPE_SECRET_KEY secret is missing.", "no_card_payment_provider");
|
|
454
|
-
}
|
|
455
|
-
return new Stripe(key);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
// Stripe amounts are in minor units; these currencies have none.
|
|
459
|
-
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
460
|
-
const minorUnits = (amount: number, currency: string) =>
|
|
461
|
-
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
462
|
-
|
|
463
|
-
export async function createCardPayment(
|
|
464
|
-
_sr: any,
|
|
465
|
-
order: any,
|
|
466
|
-
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
467
|
-
): Promise<CardPaymentPage> {
|
|
468
|
-
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
469
|
-
// names the order when Stripe's event arrives — keep it on both objects.
|
|
470
|
-
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
471
|
-
const session = await stripe().checkout.sessions.create({
|
|
472
|
-
mode: "payment",
|
|
473
|
-
line_items: [{
|
|
474
|
-
quantity: 1,
|
|
475
|
-
price_data: {
|
|
476
|
-
currency: String(order.currency || "USD").toLowerCase(),
|
|
477
|
-
product_data: { name: `Order #${order.order_number}` },
|
|
478
|
-
unit_amount: minorUnits(order.total, order.currency),
|
|
479
|
-
},
|
|
480
|
-
}],
|
|
481
|
-
customer_email: opts.customerEmail || undefined,
|
|
482
|
-
metadata,
|
|
483
|
-
payment_intent_data: { metadata },
|
|
484
|
-
success_url: opts.successUrl,
|
|
485
|
-
cancel_url: opts.cancelUrl,
|
|
486
|
-
});
|
|
487
|
-
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
488
|
-
return { url: session.url, reference: session.id };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
492
|
-
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
493
|
-
// The payment must be for THIS order — stops a reference to some other
|
|
494
|
-
// (paid) session being replayed against a different order.
|
|
495
|
-
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
499
|
-
reference: string; amount: number; currency: string; reason?: string;
|
|
500
|
-
}): Promise<{ refund_id: string }> {
|
|
501
|
-
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
502
|
-
if (!session.payment_intent) {
|
|
503
|
-
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
504
|
-
}
|
|
505
|
-
const refund = await stripe().refunds.create({
|
|
506
|
-
payment_intent: String(session.payment_intent),
|
|
507
|
-
amount: minorUnits(opts.amount, opts.currency),
|
|
508
|
-
});
|
|
509
|
-
return { refund_id: refund.id };
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
513
|
-
export interface CardWebhookEvent {
|
|
514
|
-
order_id: string;
|
|
515
|
-
order_key: string;
|
|
516
|
-
paid: boolean; // true only after signature verification — never from a raw body
|
|
517
|
-
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* Stripe webhook events. The event body is never trusted: this only names
|
|
522
|
-
* the order (from the metadata createCardPayment attached) and returns
|
|
523
|
-
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
524
|
-
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
525
|
-
* signing secret is needed. (Optional fast path that skips the API
|
|
526
|
-
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
527
|
-
*/
|
|
528
|
-
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
529
|
-
let event: any;
|
|
530
|
-
try { event = JSON.parse(payload); } catch { return null; }
|
|
531
|
-
const metadata = event?.data?.object?.metadata;
|
|
532
|
-
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
533
|
-
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
534
|
-
}
|
|
535
|
-
```
|
|
469
|
+
Four steps, whichever provider it is:
|
|
536
470
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
```js
|
|
543
|
-
await fetch("https://api.stripe.com/v1/webhook_endpoints", {
|
|
544
|
-
method: "POST",
|
|
545
|
-
headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
546
|
-
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
547
|
-
});
|
|
548
|
-
```
|
|
471
|
+
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).
|
|
472
|
+
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.
|
|
473
|
+
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.)
|
|
474
|
+
4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. **Turning a method on or off is the seed 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), idempotent, and it converges every gateway row to that set, so the same call also goes card-only or switches cards back off later. Reach for direct CRUD on `commerce.PaymentGateway` when the **record itself** needs changing — adding a new manual option, renaming one, editing its description or bank accounts, reordering them — which is what the admin's Settings → Payments screen does.
|
|
549
475
|
|
|
550
|
-
|
|
476
|
+
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`.
|
|
551
477
|
|
|
552
|
-
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.
|
|
478
|
+
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.
|
|
553
479
|
|
|
554
480
|
---
|
|
555
481
|
|
|
@@ -561,7 +487,7 @@ Post-installation is complete when every line below holds. **Do not validate the
|
|
|
561
487
|
- [ ] `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).
|
|
562
488
|
- [ ] **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).
|
|
563
489
|
- [ ] **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.
|
|
564
|
-
- [ ] **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()`.
|
|
490
|
+
- [ ] **One `<StorefrontProvider>`** above every storefront route — wrapping `<Routes>` (or a layout route's `<Outlet />`), never placed as a child of `<Routes>`, which React Router rejects (§2) — 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()`.
|
|
565
491
|
- [ ] **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.
|
|
566
492
|
|
|
567
493
|
## 6. Next
|
|
@@ -13,7 +13,7 @@ What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal
|
|
|
13
13
|
| `refundCardPayment` | admin refunds through the provider (optional — leave the stub to keep refunds manual) |
|
|
14
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 |
|
|
15
15
|
|
|
16
|
-
**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.
|
|
17
17
|
|
|
18
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.
|
|
19
19
|
|
|
@@ -41,12 +41,130 @@ The card option ships **disabled**, so on a default store none of this is custom
|
|
|
41
41
|
|
|
42
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`:
|
|
43
43
|
|
|
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
|
|
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.
|
|
45
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.
|
|
46
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.
|
|
47
47
|
|
|
48
48
|
Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
|
|
49
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
|
+
|
|
50
168
|
## Storefront requirements (unchanged by any of this)
|
|
51
169
|
|
|
52
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>
|
|
@@ -13,10 +13,14 @@ import { createStorefront, storefrontErrorCode, storefrontErrorMessage } from "@
|
|
|
13
13
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
14
14
|
*
|
|
15
15
|
* Mount it once, above every storefront page (product list, product page,
|
|
16
|
-
* cart, checkout, order-received)
|
|
16
|
+
* cart, checkout, order-received) — it wraps <Routes>, it is NOT a <Route>:
|
|
17
17
|
*
|
|
18
18
|
* import { base44 } from "@/api/base44Client";
|
|
19
|
-
* <StorefrontProvider base44={base44}>
|
|
19
|
+
* <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
|
|
20
|
+
*
|
|
21
|
+
* Inside <Routes> it throws ("is not a <Route> component"), since React Router
|
|
22
|
+
* allows only <Route>/<Fragment> children there. To cover just some routes,
|
|
23
|
+
* use a pathless layout route: element={<StorefrontProvider …><Outlet/></…>}.
|
|
20
24
|
*
|
|
21
25
|
* or, if other modules also need the raw client, create it once and share it:
|
|
22
26
|
*
|
|
@@ -102,7 +106,7 @@ export function useStorefrontState() {
|
|
|
102
106
|
const ctx = useContext(StorefrontContext);
|
|
103
107
|
if (!ctx) {
|
|
104
108
|
throw new Error(
|
|
105
|
-
"Storefront hooks need a <StorefrontProvider> above them — mount it once around your
|
|
109
|
+
"Storefront hooks need a <StorefrontProvider> above them — mount it once around your <Routes> (it wraps the router; it is not a <Route>, and inside <Routes> React Router rejects it).",
|
|
106
110
|
);
|
|
107
111
|
}
|
|
108
112
|
return ctx;
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* every pixel stays yours. The hooks own the contracts that are easy to get
|
|
7
7
|
* subtly wrong; you own the markup.
|
|
8
8
|
*
|
|
9
|
-
* Setup (once, above every storefront route
|
|
9
|
+
* Setup (once, above every storefront route — it wraps <Routes>; placed as a
|
|
10
|
+
* child of <Routes> React Router throws "is not a <Route> component"):
|
|
10
11
|
*
|
|
11
12
|
* import { StorefrontProvider } from "@/commerce/storefront";
|
|
12
13
|
* import { base44 } from "@/api/base44Client";
|