@base44/app-plugin-commerce 0.1.8 → 0.1.11
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/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/functions/commerce/payment-webhook/entry.ts +28 -39
- package/base44/shared/commerce/card-payment.ts +66 -12
- package/base44/shared/commerce/payments.ts +10 -8
- package/package.json +1 -1
- package/scripts/install.js +3 -2
- package/skills/commerce/SKILL.md +3 -3
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/installation-guidelines.md +1 -1
- package/skills/commerce/post-installation.md +148 -9
- package/skills/commerce/references/guest-access-security.md +1 -1
- package/skills/commerce/references/online-payments.md +24 -8
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +7 -6
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
|
-
- **Online card payments, order side premade** — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook) and refund records are all built. Wiring a provider (Stripe, PayPal, a local PSP…) means implementing **
|
|
11
|
+
- **Online card payments, order side premade** — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook, both premade) and refund records are all built. Wiring a provider (Stripe, PayPal, a local PSP…) means implementing **four functions in one file** — `base44/shared/commerce/card-payment.ts` — and nothing else; a complete Stripe implementation to paste in ships in [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.2. Every other payment option is manual (on-hold + instructions) and needs no code; the admin can add more in Settings → Payments. See [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md).
|
|
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 functions for the shopfront you build: `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).
|
|
@@ -99,7 +99,7 @@ If you build on Base44's hosted platform, use the Base44 agent/MCP to write the
|
|
|
99
99
|
## What's NOT included
|
|
100
100
|
|
|
101
101
|
- **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 **helper logic**: [`src/commerce/utils/`](./src/commerce/utils/) — framework-free variant-selection functions (map a Size/Color selection to a `ProductVariation` and back, per-option availability, variant price ranges) — 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.
|
|
102
|
-
- **No payment provider** — the order side of card payments is premade (see above), but charging a card needs a provider: implement the
|
|
102
|
+
- **No payment provider** — the order side of card payments is premade (see above), but charging a card needs a provider: implement the four functions in `base44/shared/commerce/card-payment.ts` to wire one (Stripe: paste-in in `skills/commerce/post-installation.md` §2.2). Until then the card option answers "card payments are not available yet" at checkout (or switch it off in Settings → Payments), and the manual gateways (bank transfer, cash on delivery, anything the admin adds) carry checkout.
|
|
103
103
|
- **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).
|
|
104
104
|
|
|
105
105
|
## Next steps
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"refunded_payment": {
|
|
22
22
|
"type": "boolean",
|
|
23
23
|
"default": false,
|
|
24
|
-
"description": "True when a real
|
|
24
|
+
"description": "True when a real refund was made at the payment provider (via shared/commerce/card-payment.ts refundCardPayment — see .agents/skills/commerce/references/online-payments.md)"
|
|
25
25
|
},
|
|
26
26
|
"restock_items": {
|
|
27
27
|
"type": "boolean",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "commerce.PaymentGateway",
|
|
3
3
|
"type": "object",
|
|
4
|
-
"description": "Payment gateway configuration. Seeded: offline (settled outside the store) and
|
|
4
|
+
"description": "Payment gateway configuration. Seeded: offline (settled outside the store) and card (processed by commerce/payments + commerce/payment-webhook via shared/commerce/card-payment.ts). Secrets are never stored here; use Base44 secrets.",
|
|
5
5
|
"properties": {
|
|
6
6
|
"slug": {
|
|
7
7
|
"type": "string",
|
|
8
8
|
"minLength": 1,
|
|
9
|
-
"description": "Unique gateway id, e.g. offline |
|
|
9
|
+
"description": "Unique gateway id, e.g. offline | card"
|
|
10
10
|
},
|
|
11
11
|
"title": {
|
|
12
12
|
"type": "string",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"settings": {
|
|
37
37
|
"type": "object",
|
|
38
|
-
"description": "Per-gateway settings. offline: {account_details: [{account_name, account_number, bank_name, sort_code, iban, bic}]};
|
|
38
|
+
"description": "Per-gateway settings. offline: {account_details: [{account_name, account_number, bank_name, sort_code, iban, bic}]}; card: {} — provider config lives in code and backend secrets, never here; see .agents/skills/commerce/references/online-payments.md"
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"required": ["slug"],
|
|
@@ -1,66 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* commerce/payment-webhook —
|
|
3
|
-
* provider
|
|
2
|
+
* commerce/payment-webhook — the provider's server-to-server payment callback.
|
|
3
|
+
* PREMADE: wiring a payment provider does NOT mean editing this file. All
|
|
4
|
+
* provider-specific logic — including validating the incoming event — lives
|
|
5
|
+
* in `base44/shared/commerce/card-payment.ts` (`parseWebhook`), the one file
|
|
6
|
+
* to implement.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Trust model: an event body is never believed on its own. `parseWebhook`
|
|
9
|
+
* names the order (from the metadata `createCardPayment` attached) and only
|
|
10
|
+
* vouches `paid: true` after real signature verification; otherwise the event
|
|
11
|
+
* is a nudge and whether money arrived is asked of the provider itself
|
|
12
|
+
* through `checkCardPaymentPaid`. Either way a forged call can never mark an
|
|
13
|
+
* order paid, and the `order_key` match below stops an event being pointed
|
|
14
|
+
* at another order.
|
|
10
15
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
16
|
+
* This is one of the two idempotent confirmation paths — it covers the buyer
|
|
17
|
+
* who pays and closes the tab; the other is the customer returning to the
|
|
18
|
+
* storefront, which calls `commerce/payments` `complete-return`. Whichever
|
|
19
|
+
* runs second is a no-op. Register this function's URL with the provider
|
|
20
|
+
* (e.g. a Stripe webhook endpoint for `checkout.session.completed`).
|
|
14
21
|
*
|
|
15
22
|
* Unlike every other function here this takes the provider's **raw body**
|
|
16
23
|
* rather than an `{action, ...}` envelope, because webhook signatures are
|
|
17
24
|
* computed over those exact bytes.
|
|
18
25
|
*
|
|
19
|
-
* Until implemented, every request answers
|
|
26
|
+
* Until `parseWebhook` is implemented, every request answers
|
|
27
|
+
* 400 `webhook_not_implemented`.
|
|
20
28
|
*/
|
|
21
29
|
import { createClientFromRequest } from "npm:@base44/sdk";
|
|
30
|
+
import { HttpError } from "../../../shared/commerce/auth.ts";
|
|
22
31
|
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
23
32
|
import { confirmCardPayment } from "../../../shared/commerce/payments.ts";
|
|
33
|
+
import { parseWebhook } from "../../../shared/commerce/card-payment.ts";
|
|
24
34
|
|
|
25
35
|
/** Providers retry on non-2xx; answer 200 for anything we deliberately ignore. */
|
|
26
36
|
const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
|
|
27
37
|
const fail = (status: number, error: string, code: string) =>
|
|
28
38
|
Response.json({ success: false, error, code }, { status });
|
|
29
39
|
|
|
30
|
-
interface CardWebhookEvent {
|
|
31
|
-
/** The order the event is about — read it from the event's metadata. */
|
|
32
|
-
order_id: string;
|
|
33
|
-
/** Must match the order's key; attach both to the payment when creating it. */
|
|
34
|
-
order_key: string;
|
|
35
|
-
/**
|
|
36
|
-
* Set true ONLY when you verified the request signature AND the event says
|
|
37
|
-
* the payment succeeded. When false, the event is treated as a nudge naming
|
|
38
|
-
* an order, and whether money arrived is asked of the provider through
|
|
39
|
-
* `checkCardPaymentPaid` — useful when no signing secret is available.
|
|
40
|
-
*/
|
|
41
|
-
paid: boolean;
|
|
42
|
-
/** The provider's payment id, if the event carries one. */
|
|
43
|
-
reference?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* ← IMPLEMENT THIS for your provider: verify the signature (over the raw
|
|
48
|
-
* `payload` bytes) and pull out the order reference. Return null for events
|
|
49
|
-
* that aren't about a payment for one of this store's orders.
|
|
50
|
-
*/
|
|
51
|
-
async function parseWebhook(_req: Request, _payload: string): Promise<CardWebhookEvent | null> {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
40
|
Deno.serve(async (req: Request) => {
|
|
56
41
|
try {
|
|
57
42
|
const payload = await req.text();
|
|
58
43
|
|
|
59
44
|
const event = await parseWebhook(req, payload);
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
// Not about one of this store's orders — acknowledge so the provider
|
|
46
|
+
// doesn't retry.
|
|
47
|
+
if (!event || !event.order_id) {
|
|
48
|
+
return ok({ ignored: true, reason: "no_order_reference" });
|
|
62
49
|
}
|
|
63
|
-
if (!event.order_id) return ok({ ignored: true, reason: "no_order_reference" });
|
|
64
50
|
|
|
65
51
|
const base44 = createClientFromRequest(req);
|
|
66
52
|
const sr = base44.asServiceRole;
|
|
@@ -89,6 +75,9 @@ Deno.serve(async (req: Request) => {
|
|
|
89
75
|
status: result.order.status,
|
|
90
76
|
});
|
|
91
77
|
} catch (e) {
|
|
78
|
+
if (e instanceof HttpError) {
|
|
79
|
+
return fail(e.status, e.message, e.code ?? "error");
|
|
80
|
+
}
|
|
92
81
|
// 500 makes the provider retry, which is what we want for a transient failure.
|
|
93
82
|
console.error("commerce/payment-webhook error:", e);
|
|
94
83
|
return fail(500, (e as Error)?.message ?? "Internal error", "internal_error");
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Card payments —
|
|
3
|
-
* (Stripe, PayPal, Adyen, a local PSP…).
|
|
4
|
-
*
|
|
2
|
+
* Card payments — the ONE file to implement when wiring a payment provider
|
|
3
|
+
* (Stripe, PayPal, Adyen, a local PSP…). Implement the four functions below;
|
|
4
|
+
* no other file changes:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* createCardPayment → checkout + payment links (hosted payment page)
|
|
7
|
+
* checkCardPaymentPaid → confirmation (return page, webhook, admin "Check payment")
|
|
8
|
+
* refundCardPayment → admin refunds through the provider
|
|
9
|
+
* parseWebhook → validate the provider's server-to-server event
|
|
10
|
+
*
|
|
11
|
+
* Any provider works; a ready-made reference implementation (Stripe) is in
|
|
12
|
+
* `.agents/skills/commerce/post-installation.md` §2.2 to paste over this
|
|
13
|
+
* file — another provider implements the same functions against its own API.
|
|
14
|
+
*
|
|
15
|
+
* Everything around these functions is already built: checkout creates the
|
|
16
|
+
* order, prices it, holds stock and routes the customer to the URL you
|
|
17
|
+
* return; the return page and the payment webhook confirm payment
|
|
18
|
+
* idempotently and move the order to processing (stock, emails, download
|
|
19
|
+
* permissions, webhooks all fire from there); admin refunds write the refund
|
|
20
|
+
* record and restock. `commerce/payment-webhook` (the function) is PREMADE —
|
|
21
|
+
* it calls `parseWebhook` from here and never trusts an event that this file
|
|
22
|
+
* hasn't vouched for.
|
|
12
23
|
*
|
|
13
24
|
* Until implemented, the Credit Card checkout option answers
|
|
14
25
|
* 503 `no_card_payment_provider`.
|
|
@@ -34,9 +45,9 @@ export interface CardPaymentPage {
|
|
|
34
45
|
* Create a payment page for `order.total` in `order.currency`.
|
|
35
46
|
*
|
|
36
47
|
* Send the customer back to `successUrl` / `cancelUrl` after they pay or give
|
|
37
|
-
* up (both already carry order_id/order_key for the return page).
|
|
38
|
-
*
|
|
39
|
-
* webhook
|
|
48
|
+
* up (both already carry order_id/order_key for the return page). Attach
|
|
49
|
+
* `order.id` and `order.order_key` to the payment's metadata — that is how
|
|
50
|
+
* the premade webhook names the order when the provider's event comes in.
|
|
40
51
|
*/
|
|
41
52
|
export async function createCardPayment(
|
|
42
53
|
_sr: any,
|
|
@@ -78,3 +89,46 @@ export async function refundCardPayment(_sr: any, _order: any, _opts: {
|
|
|
78
89
|
"card_refund_not_implemented",
|
|
79
90
|
);
|
|
80
91
|
}
|
|
92
|
+
|
|
93
|
+
/** What `parseWebhook` distills a provider's webhook event into. */
|
|
94
|
+
export interface CardWebhookEvent {
|
|
95
|
+
/** The order the event is about — read it from the event's metadata
|
|
96
|
+
* (`createCardPayment` attached it). */
|
|
97
|
+
order_id: string;
|
|
98
|
+
/** Must match the order's key; the premade webhook rejects the event
|
|
99
|
+
* otherwise, so a forged event can't be pointed at another order. */
|
|
100
|
+
order_key: string;
|
|
101
|
+
/**
|
|
102
|
+
* Set true ONLY when you verified the request signature over the raw
|
|
103
|
+
* payload bytes AND the event says the payment succeeded. When false, the
|
|
104
|
+
* event is treated as an unverified *nudge* naming an order: whether money
|
|
105
|
+
* actually arrived is then asked of the provider itself through
|
|
106
|
+
* `checkCardPaymentPaid`. `paid: false` therefore needs no signing secret
|
|
107
|
+
* and is already forgery-proof — never return true from an unverified body.
|
|
108
|
+
*/
|
|
109
|
+
paid: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The provider's payment id, ONLY if the event is signature-verified.
|
|
112
|
+
* Leave unset otherwise — the premade flow then verifies the reference
|
|
113
|
+
* stored on the order at checkout, so a forged event can't smuggle in a
|
|
114
|
+
* reference to some other (paid) payment.
|
|
115
|
+
*/
|
|
116
|
+
reference?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate an incoming provider webhook event (raw request + raw body — the
|
|
121
|
+
* exact bytes, for signature schemes) and name the order it is about. Called
|
|
122
|
+
* by the premade `commerce/payment-webhook` function on every delivery; the
|
|
123
|
+
* verification that money actually arrived happens after this, against the
|
|
124
|
+
* provider, per the `paid` contract above. Return null for events that
|
|
125
|
+
* aren't about a payment for one of this store's orders (answered 200 so the
|
|
126
|
+
* provider doesn't retry).
|
|
127
|
+
*/
|
|
128
|
+
export async function parseWebhook(_req: Request, _payload: string): Promise<CardWebhookEvent | null> {
|
|
129
|
+
throw new HttpError(
|
|
130
|
+
400,
|
|
131
|
+
"This store's payment webhook is not implemented.",
|
|
132
|
+
"webhook_not_implemented",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Card payment plumbing — the premade order-side half of online payments.
|
|
3
3
|
*
|
|
4
|
-
* The provider-specific half lives in exactly
|
|
5
|
-
* when wiring a provider
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* The provider-specific half lives in exactly ONE file an agent implements
|
|
5
|
+
* when wiring a provider: `shared/commerce/card-payment.ts` — four functions
|
|
6
|
+
* (create a payment page, check it was paid, refund it, validate a webhook
|
|
7
|
+
* event). Any provider works;
|
|
8
|
+
* a reference implementation (Stripe) is in
|
|
9
|
+
* `.agents/skills/commerce/post-installation.md` §2.2, and the
|
|
10
|
+
* provider-agnostic rules are in
|
|
11
|
+
* `.agents/skills/commerce/references/online-payments.md`.
|
|
11
12
|
*
|
|
12
13
|
* Everything here — return URLs, storing the payment reference on the order,
|
|
13
14
|
* idempotent confirmation that moves the order to processing, refund routing —
|
|
14
15
|
* is already wired into checkout, the payments function, the webhook and admin
|
|
15
|
-
* refunds.
|
|
16
|
+
* refunds. The payment webhook (`functions/commerce/payment-webhook`) is
|
|
17
|
+
* premade too. None of it needs touching to add a provider.
|
|
16
18
|
*/
|
|
17
19
|
import { HttpError } from "./auth.ts";
|
|
18
20
|
import { round2 } from "./money.ts";
|
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.11",
|
|
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/scripts/install.js
CHANGED
|
@@ -96,8 +96,9 @@
|
|
|
96
96
|
// remove the old copy so agents don't keep reading the stale one.
|
|
97
97
|
["skills", "commerce"],
|
|
98
98
|
[".agents", "skills", "commerce", "references", "stripe-payments.md"], // → references/online-payments.md
|
|
99
|
-
// Payments moved to the
|
|
100
|
-
// the Stripe adapter and the provider-status
|
|
99
|
+
// Payments moved to the card-payment.ts model (one file to implement;
|
|
100
|
+
// the webhook is premade). The old Stripe adapter and the provider-status
|
|
101
|
+
// hook are gone.
|
|
101
102
|
["base44", "shared", "commerce", "stripe.ts"],
|
|
102
103
|
["src", "commerce", "admin", "hooks", "usePaymentProvider.js"],
|
|
103
104
|
// Attributes and their values are edited in the Attributes section of the
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ Operational guidance for extending, operating and building on the Base44 Commerc
|
|
|
13
13
|
|
|
14
14
|
## IMPORTANT — first-time installation
|
|
15
15
|
|
|
16
|
-
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**: 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 — §2) — and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout
|
|
16
|
+
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 — §2) — **card payments in one file** (§2.2: a complete Stripe implementation to paste over `shared/commerce/card-payment.ts`; the payment webhook is premade), and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout. 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).
|
|
17
17
|
|
|
18
18
|
## Working on the UI
|
|
19
19
|
|
|
@@ -34,7 +34,7 @@ Agents keep shipping storefronts that miss these, and each one breaks buying out
|
|
|
34
34
|
|
|
35
35
|
2. **Checkout must present shipping options and send a choice.** Call `set-shipping-address` **as soon as the customer provides an address** — it recalculates the options and cost, and fails with `400 shipping_not_available` when the store doesn't ship there. Then read `shipping_status` on the returned cart: `auto_selected` (one option, already applied) · `chosen` · `choice_required` → **you must 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 the choice by looking it up in `available_shipping_methods` for its `title`/`cost`, never by rendering the id or dotting into it.)
|
|
36
36
|
|
|
37
|
-
3. **Handle card payments, and build `/order-received`.** Choosing the card gateway returns `payment.checkout_url` — redirect there (until a payment provider is implemented it answers `503 no_card_payment_provider`; offer the other methods). Every payment link comes back to `/order-received`, which **you must implement**: call `commerce/payments` `complete-return` with the query params and render its `state`. 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 — `add-item` silently starts a fresh cart when the stored token is stale.
|
|
37
|
+
3. **Handle card payments, and build `/order-received`.** Choosing the card gateway returns `payment.checkout_url` — redirect there (until a payment provider is implemented — one file, Stripe paste-in in [`post-installation.md` §2.2](./post-installation.md#22-payments--one-file-any-provider-stripe-as-the-reference) — it answers `503 no_card_payment_provider`; offer the other methods). Every payment link comes back to `/order-received`, which **you must implement**: call `commerce/payments` `complete-return` with the query params and render its `state`. 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 — `add-item` silently starts a fresh cart when the stored token is stale.
|
|
38
38
|
|
|
39
39
|
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 `set-shipping-address`, and `shipping-promos.js` normalizes the rules wherever the records *are* in hand. No rule means no banner.
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@ Open the matching file under `.agents/skills/commerce/references/` only when a t
|
|
|
55
55
|
| 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) |
|
|
56
56
|
| 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) |
|
|
57
57
|
| 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) |
|
|
58
|
-
| Online payments | **any storefront or checkout work** — the order side (checkout, confirmation, payment links, refund records)
|
|
58
|
+
| Online payments | **any storefront or checkout work** — 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`; Stripe = paste [`post-installation.md` §2.2](./post-installation.md#22-payments--one-file-any-provider-stripe-as-the-reference)) | [`references/online-payments.md`](./references/online-payments.md) |
|
|
59
59
|
| Scheduled work | recurring maintenance — stock-hold release, abandoned-cart cleanup, webhook-log pruning, counter-drift repair | [`references/scheduled-work.md`](./references/scheduled-work.md) |
|
|
60
60
|
| Emails | transactional order emails, per-type overrides, deliverability, the email log | [`references/emails.md`](./references/emails.md) |
|
|
61
61
|
| Webhooks | outbound webhooks, HMAC signing, delivery log, auto-disable behavior | [`references/webhooks.md`](./references/webhooks.md) |
|
|
@@ -178,7 +178,7 @@ Actions: `create-link` · `complete-return` · `verify` — the admin side of on
|
|
|
178
178
|
- **`verify`** — `{ order_id }` → `{ paid, already_confirmed, status, order }`: re-asks the provider and moves the order to `processing` when the money is there. Idempotent.
|
|
179
179
|
- **`complete-return`** — `{ order_id, order_key, payment?, return_url? }` → `{ state, paid, already_confirmed, status, order, payment_link, payment_instructions }`: what the storefront's **mandatory** `/order-received` page calls — confirm, progress the order, and hand back a render-ready result plus a fresh payment link while unpaid (card) or the gateway's payment instructions (manual).
|
|
180
180
|
|
|
181
|
-
`commerce/payment-webhook` is the provider's server-to-server callback (raw body, no `action` envelope).
|
|
181
|
+
`commerce/payment-webhook` is the provider's server-to-server callback (raw body, no `action` envelope). The function is **premade** — it validates every event through `card-payment.ts`'s `parseWebhook` and never trusts an event body on its own: an unverified event only names an order, and payment is verified through `checkCardPaymentPaid` against the provider's API, so no signing secret is needed. Until `card-payment.ts` is implemented it answers `400 webhook_not_implemented`; events it can't tie to an order are acknowledged with `200 { ignored: true }` so providers don't retry. See [`../references/online-payments.md`](../references/online-payments.md).
|
|
182
182
|
|
|
183
183
|
## commerce/seed-store
|
|
184
184
|
|
|
@@ -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 the payment provider integration — four functions in one file (Stripe = paste [`post-installation.md` §2.2](../post-installation.md#22-payments--one-file-any-provider-stripe-as-the-reference); other providers: [`references/online-payments.md`](../references/online-payments.md)). Payment methods and the currency are admin-owned data — always render them from `get-store-info` rather than a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
|
|
40
40
|
|
|
41
41
|
## Conventions
|
|
42
42
|
|
|
@@ -312,7 +312,7 @@ Separate function, same guest-bearer rule (`order_id` + `order_key`; an admin ma
|
|
|
312
312
|
|
|
313
313
|
> **You must build the `/order-received` page** — it is mandatory for payment links to work. No storefront UI ships with the template, and without that route a paying customer lands on a 404 *and* the order never gets marked paid. It only needs to call `complete-return` and render its three states: see [`../references/online-payments.md`](../references/online-payments.md).
|
|
314
314
|
|
|
315
|
-
Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab. Whichever path runs second is a no-op.
|
|
315
|
+
Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab. Whichever path runs second is a no-op. The webhook function is premade (it verifies through the provider's API, so no signing secret); wiring a payment provider means implementing four functions in one file — see [`../references/online-payments.md`](../references/online-payments.md).
|
|
316
316
|
|
|
317
317
|
### `confirm-payment`
|
|
318
318
|
Post-payment hook for an order on the card gateway. **Payload:** `{ order_id, order_key }`.
|
|
@@ -85,7 +85,7 @@ The template ships an AI copilot for store operators:
|
|
|
85
85
|
|
|
86
86
|
## 4. Building on it — the four storefront requirements
|
|
87
87
|
|
|
88
|
-
If your work includes a customer-facing shopfront,
|
|
88
|
+
If your work includes a customer-facing shopfront, the quick start in [`post-installation.md` §3](./post-installation.md#3-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; card payment redirect plus an `/order-received` page; and no offers the store isn't configured for. 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.)
|
|
89
89
|
|
|
90
90
|
## 5. Next steps
|
|
91
91
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Post-installation
|
|
2
2
|
|
|
3
|
-
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `.agents/skills/commerce/post-installation.md`.
|
|
3
|
+
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, wire payments, and build the storefront from the quick start below. Installed into the app at `.agents/skills/commerce/post-installation.md`.
|
|
4
|
+
|
|
5
|
+
> **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe 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, seeding, product list, product page, cart, checkout, order-received, card payments) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
|
|
4
6
|
|
|
5
7
|
---
|
|
6
8
|
|
|
@@ -131,13 +133,136 @@ The response reports everything:
|
|
|
131
133
|
"currency": { "value": "EUR", "action": "created" } } // "updated" | "unchanged" on re-runs; null when not passed
|
|
132
134
|
```
|
|
133
135
|
|
|
134
|
-
**Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
|
|
136
|
+
**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. Image generation is usually the **slowest step of the whole install** — kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed once the URLs are back. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
|
|
135
137
|
|
|
136
138
|
A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
|
|
137
139
|
|
|
138
|
-
### 2.2 Payments
|
|
140
|
+
### 2.2 Payments — one file, any provider (Stripe as the reference)
|
|
141
|
+
|
|
142
|
+
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.
|
|
143
|
+
|
|
144
|
+
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**. Until the file is implemented the card option answers `503 no_card_payment_provider` at checkout (or switch it off in Settings → Payments).
|
|
145
|
+
|
|
146
|
+
| Function | Backs |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `createCardPayment` | checkout + payment links (creates the hosted payment page) |
|
|
149
|
+
| `checkCardPaymentPaid` | confirmation — the return page, the webhook and the admin's "Check payment" all verify through it, against the provider's API |
|
|
150
|
+
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
151
|
+
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
152
|
+
|
|
153
|
+
**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. 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)):
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
157
|
+
import Stripe from "npm:stripe@18";
|
|
158
|
+
import { HttpError } from "./auth.ts";
|
|
159
|
+
|
|
160
|
+
export interface CardPaymentPage {
|
|
161
|
+
url: string; // where the customer goes to pay
|
|
162
|
+
reference: string; // the provider's id for this payment, stored on the order
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const stripe = () => {
|
|
166
|
+
const key = Deno.env.get("STRIPE_SECRET_KEY");
|
|
167
|
+
if (!key) {
|
|
168
|
+
throw new HttpError(503, "Card payments are not configured — the STRIPE_SECRET_KEY secret is missing.", "no_card_payment_provider");
|
|
169
|
+
}
|
|
170
|
+
return new Stripe(key);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Stripe amounts are in minor units; these currencies have none.
|
|
174
|
+
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
175
|
+
const minorUnits = (amount: number, currency: string) =>
|
|
176
|
+
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
177
|
+
|
|
178
|
+
export async function createCardPayment(
|
|
179
|
+
_sr: any,
|
|
180
|
+
order: any,
|
|
181
|
+
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
182
|
+
): Promise<CardPaymentPage> {
|
|
183
|
+
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
184
|
+
// names the order when Stripe's event arrives — keep it on both objects.
|
|
185
|
+
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
186
|
+
const session = await stripe().checkout.sessions.create({
|
|
187
|
+
mode: "payment",
|
|
188
|
+
line_items: [{
|
|
189
|
+
quantity: 1,
|
|
190
|
+
price_data: {
|
|
191
|
+
currency: String(order.currency || "USD").toLowerCase(),
|
|
192
|
+
product_data: { name: `Order #${order.order_number}` },
|
|
193
|
+
unit_amount: minorUnits(order.total, order.currency),
|
|
194
|
+
},
|
|
195
|
+
}],
|
|
196
|
+
customer_email: opts.customerEmail || undefined,
|
|
197
|
+
metadata,
|
|
198
|
+
payment_intent_data: { metadata },
|
|
199
|
+
success_url: opts.successUrl,
|
|
200
|
+
cancel_url: opts.cancelUrl,
|
|
201
|
+
});
|
|
202
|
+
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
203
|
+
return { url: session.url, reference: session.id };
|
|
204
|
+
}
|
|
139
205
|
|
|
140
|
-
|
|
206
|
+
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
207
|
+
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
208
|
+
// The payment must be for THIS order — stops a reference to some other
|
|
209
|
+
// (paid) session being replayed against a different order.
|
|
210
|
+
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
214
|
+
reference: string; amount: number; currency: string; reason?: string;
|
|
215
|
+
}): Promise<{ refund_id: string }> {
|
|
216
|
+
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
217
|
+
if (!session.payment_intent) {
|
|
218
|
+
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
219
|
+
}
|
|
220
|
+
const refund = await stripe().refunds.create({
|
|
221
|
+
payment_intent: String(session.payment_intent),
|
|
222
|
+
amount: minorUnits(opts.amount, opts.currency),
|
|
223
|
+
});
|
|
224
|
+
return { refund_id: refund.id };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
228
|
+
export interface CardWebhookEvent {
|
|
229
|
+
order_id: string;
|
|
230
|
+
order_key: string;
|
|
231
|
+
paid: boolean; // true only after signature verification — never from a raw body
|
|
232
|
+
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stripe webhook events. The event body is never trusted: this only names
|
|
237
|
+
* the order (from the metadata createCardPayment attached) and returns
|
|
238
|
+
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
239
|
+
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
240
|
+
* signing secret is needed. (Optional fast path that skips the API
|
|
241
|
+
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
242
|
+
*/
|
|
243
|
+
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
244
|
+
let event: any;
|
|
245
|
+
try { event = JSON.parse(payload); } catch { return null; }
|
|
246
|
+
const metadata = event?.data?.object?.metadata;
|
|
247
|
+
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
248
|
+
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Then two steps and payments are done (every provider follows this same shape — an API-credential secret, plus registering the premade webhook URL; only the Stripe specifics below vary):
|
|
253
|
+
|
|
254
|
+
1. **Secret**: ask the user for their Stripe **secret key** and store it as the `STRIPE_SECRET_KEY` app secret (backend env — never in code, never in an entity). Test keys (`sk_test_…`) work end to end.
|
|
255
|
+
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):
|
|
256
|
+
|
|
257
|
+
```js
|
|
258
|
+
await fetch("https://api.stripe.com/v1/webhook_endpoints", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
261
|
+
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
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. **Nothing else to read or edit**: [`references/online-payments.md`](./references/online-payments.md) is only for *other* providers or signature-verified webhooks.
|
|
141
266
|
|
|
142
267
|
---
|
|
143
268
|
|
|
@@ -169,7 +294,7 @@ const { products, page, per_page, has_next } = await inv("commerce/storefront-ca
|
|
|
169
294
|
}); // sort: -created_date | name | price | -price | popularity | rating
|
|
170
295
|
```
|
|
171
296
|
|
|
172
|
-
Each row is a full product record — for a card use `name`, `images[0]?.src
|
|
297
|
+
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 `{ action: "list-categories" }` (a tree via `parent_id`). That is the whole card — no other call or reference needed for the list view.
|
|
173
298
|
|
|
174
299
|
**Carry forward:** each card links to the product page by **`slug`**.
|
|
175
300
|
|
|
@@ -205,7 +330,9 @@ const cart = await inv("commerce/storefront-cart", {
|
|
|
205
330
|
localStorage.setItem("cart_token", cart.cart_token); // ALWAYS — the token may be a new cart's
|
|
206
331
|
```
|
|
207
332
|
|
|
208
|
-
A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
|
|
333
|
+
A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call. On the other cart actions a `404 cart_not_found|cart_expired` means the cached token went stale — clear it and treat the cart as empty.
|
|
334
|
+
|
|
335
|
+
**What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder when empty), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
|
|
209
336
|
|
|
210
337
|
**Carry forward:** the **`cart_token`** — from the response, every time.
|
|
211
338
|
|
|
@@ -257,8 +384,8 @@ A store with exactly **one shipping zone** shows its options (and, auto-selected
|
|
|
257
384
|
The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods, which come from `info.payment_gateways` (step 1) — every gateway the admin has **enabled**:
|
|
258
385
|
|
|
259
386
|
```js
|
|
260
|
-
//
|
|
261
|
-
//
|
|
387
|
+
// card payments = ONE file to implement — §2.2 above has the complete
|
|
388
|
+
// Stripe implementation to paste; no other reading needed
|
|
262
389
|
const gateways = info.payment_gateways; // [{ slug, title, description, online }] — admin-owned data
|
|
263
390
|
// several → render a picker using the admin's title/description as the labels
|
|
264
391
|
// exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
|
|
@@ -308,6 +435,18 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
|
|
|
308
435
|
|
|
309
436
|
---
|
|
310
437
|
|
|
311
|
-
## 4.
|
|
438
|
+
## 4. Done when
|
|
439
|
+
|
|
440
|
+
Post-installation is complete when every line below holds — verify against this list instead of re-reading docs:
|
|
441
|
+
|
|
442
|
+
- [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
|
|
443
|
+
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs.
|
|
444
|
+
- [ ] Product list renders from `list-products` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
|
|
445
|
+
- [ ] Product page renders from `get-product` with one selector per attribute, resolving to `view.addToCart`.
|
|
446
|
+
- [ ] Cart → address (`set-shipping-address`) → shipping choice → `place-order` completes, and the `cart_token` is re-persisted from every response.
|
|
447
|
+
- [ ] `/order-received` calls `complete-return` and renders `paid` / `unpaid` / `cancelled`.
|
|
448
|
+
- [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway switched off in Settings → Payments.
|
|
449
|
+
|
|
450
|
+
## 5. Next
|
|
312
451
|
|
|
313
452
|
Continue with the commerce skill — [`.agents/skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), payment provider wiring, scheduled maintenance, emails, webhooks, and operational limits.
|
|
@@ -13,6 +13,6 @@ The storefront functions run as the service role, so RLS is not protecting the c
|
|
|
13
13
|
|
|
14
14
|
- **Identity comes from the session, never from the body.** An email in the payload is a claim, not a credential. Use `getCallerUser(base44)` and `requireUser(user)` from `shared/commerce/auth.ts`, and `ownsEmail(user, email)` before writing to anything keyed on someone's address. This is why `submit-review` requires a signed-in customer (a `reviewer_email` field is ignored) and why a guest checkout attaches to an existing `commerce.Customer` without rewriting its saved name and addresses — otherwise knowing a customer's email would be enough to redirect where their next order ships, or to post a review in their name.
|
|
15
15
|
- **Anything a person owns needs authentication, not just an email.** If you add wishlists, loyalty, saved payment details, subscriptions or support tickets, gate the write on `requireUser` and derive the owner from `user.email` / `user.id`. Guest access is only ever by bearer token (`cart_token`, `order_key`) for the *one* record that token names. (Reviews are the deliberate exception — public by email, moderated instead of authenticated; a session email still always beats the payload.)
|
|
16
|
-
- **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmCardPayment()`, which asks the provider about the payment reference stored on the order. Wiring a provider means implementing the
|
|
16
|
+
- **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmCardPayment()`, which asks the provider about the payment reference stored on the order. Wiring a provider means implementing the four functions in `shared/commerce/card-payment.ts` (see [`online-payments.md`](./online-payments.md)), never a new "confirm" endpoint.
|
|
17
17
|
- **A bearer token authorizes one record.** `order_key` gets you *that* order; it is not a licence to name someone else's ids in the same request. Match every id in the payload back to the record the token opened.
|
|
18
18
|
- **Don't return more than the caller asked about.** Serialize customer-facing orders through `serializeOrderForCustomer()`, and don't let a response reveal whether another person's email exists, bought something, or has an account — a boolean in an error body is an enumeration oracle.
|
|
@@ -2,31 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
The store ships with a **Credit card** checkout option (`commerce.PaymentGateway` slug `card`) and everything around it already built — order creation, totals, stock holds, the two idempotent confirmation paths (customer return + provider webhook), payment links for unpaid orders, refund records, emails, and the admin's payment panel.
|
|
4
4
|
|
|
5
|
-
What it does **not** ship with is a payment provider. Wiring one (Stripe, PayPal, Adyen, a local PSP…) means implementing **
|
|
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
|
-
|
|
|
8
|
-
|
|
9
|
-
|
|
|
10
|
-
|
|
|
7
|
+
| Function | Backs |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `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 |
|
|
10
|
+
| `checkCardPaymentPaid` | confirmation: ask the provider whether `reference` was paid **and is the payment for this order** — used by the return page, the webhook and the admin's "Check payment" |
|
|
11
|
+
| `refundCardPayment` | admin refunds through the provider (optional — leave the stub to keep refunds manual) |
|
|
12
|
+
| `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 |
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
**Wiring Stripe? Don't start here** — [`post-installation.md` §2.2](../post-installation.md#22-payments--one-file-any-provider-stripe-as-the-reference) has the complete implementation of all four functions to paste in, plus the secret and webhook-endpoint steps. This reference is for other providers and for webhook customization.
|
|
15
|
+
|
|
16
|
+
Until the file is implemented, picking Credit card at checkout answers `503 no_card_payment_provider` (the storefront should offer the other methods); the admin can also switch the card option off in Settings → Payments to hide it. 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.
|
|
13
17
|
|
|
14
18
|
## How the premade flow works
|
|
15
19
|
|
|
16
20
|
1. **Checkout** (`commerce/storefront-checkout` `place-order` with the `card` gateway): the order is created `pending`, `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` is called, the returned `reference` is stored on the order (`_payment_reference` meta), and the customer is redirected to `url`. The return URLs already carry `order_id`, `order_key` and `payment=success|cancel`.
|
|
17
21
|
2. **Confirmation — two idempotent paths**, whichever runs second is a no-op:
|
|
18
22
|
- **Customer return**: the `/order-received` page calls `commerce/payments` `complete-return`, which runs `checkCardPaymentPaid(sr, order, reference)` and, when true, moves the order to `processing` (stock/email/webhook side-effects fire from the transition).
|
|
19
|
-
- **Webhook**: register `commerce/payment-webhook`'s URL with the provider
|
|
23
|
+
- **Webhook**: register `commerce/payment-webhook`'s URL with the provider — it covers the buyer who pays and closes the tab. The function is **premade** and never trusts an event body on its own: it hands the raw request to your `parseWebhook`, which names the order from the `order_id`/`order_key` metadata `createCardPayment` attached. A `paid: false` event is an unverified *nudge* — payment is then verified through `checkCardPaymentPaid` against the provider's API, which is why **no signing secret is needed**: a forged event can at worst trigger a re-check, never mark an order paid, and the `order_key` match stops an event being pointed at another order. `paid: true` is trusted directly and is therefore only allowed after real signature verification.
|
|
20
24
|
3. **Payment links**: `commerce/payments` `create-link` mints a fresh page for any unpaid order via the same `createCardPayment` — used by the admin's payment-link button and the order-received page's "Pay now".
|
|
21
25
|
4. **Refunds**: `commerce/admin-refunds` with `refund_payment: true` calls `refundCardPayment` **before** writing the local record (a failed provider refund writes nothing). While unimplemented it answers `501 card_refund_not_implemented` — record the refund without `refund_payment` and return the money from the provider's own dashboard.
|
|
22
26
|
|
|
23
27
|
## Implementation rules
|
|
24
28
|
|
|
25
29
|
- **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.
|
|
26
|
-
- **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.
|
|
30
|
+
- **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.
|
|
31
|
+
- **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.
|
|
32
|
+
- **`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.
|
|
27
33
|
- **Amounts**: `order.total` is in display units (e.g. `12.34`) with `order.currency`; convert to the provider's minor units yourself if it needs them.
|
|
28
34
|
- The shared helpers in `base44/shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
29
35
|
|
|
36
|
+
## `parseWebhook` in depth
|
|
37
|
+
|
|
38
|
+
`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`:
|
|
39
|
+
|
|
40
|
+
- **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 reference implementation does.
|
|
41
|
+
- **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.
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
44
|
+
Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
|
|
45
|
+
|
|
30
46
|
## Storefront requirements (unchanged by any of this)
|
|
31
47
|
|
|
32
48
|
- Redirect to `payment.checkout_url` when `place-order` returns `payment.status === "requires_payment"`.
|
|
@@ -205,12 +205,13 @@ export default function PaymentsSettings() {
|
|
|
205
205
|
</div>
|
|
206
206
|
|
|
207
207
|
{/* The "card" option redirects to a provider-hosted payment page.
|
|
208
|
-
Wiring a provider means implementing the
|
|
209
|
-
shared/commerce/card-payment.ts
|
|
210
|
-
|
|
211
|
-
then, picking it at checkout answers
|
|
212
|
-
Deliberately not shown to the
|
|
213
|
-
guidance, not store
|
|
208
|
+
Wiring a provider means implementing the four functions in
|
|
209
|
+
shared/commerce/card-payment.ts (Stripe: paste-in in
|
|
210
|
+
.agents/skills/commerce/post-installation.md §2.2; the payment
|
|
211
|
+
webhook is premade). Until then, picking it at checkout answers
|
|
212
|
+
503 no_card_payment_provider. Deliberately not shown to the
|
|
213
|
+
store operator — it's developer guidance, not store
|
|
214
|
+
configuration. */}
|
|
214
215
|
</div>
|
|
215
216
|
))}
|
|
216
217
|
</CardContent>
|