@base44/app-plugin-commerce 0.1.8 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/base44/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/functions/commerce/seed-store/entry.ts +50 -2
- package/base44/shared/commerce/card-payment.ts +72 -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 +4 -3
- package/skills/commerce/docs/api-admin.md +2 -2
- package/skills/commerce/docs/api-storefront.md +2 -2
- package/skills/commerce/installation-guidelines.md +1 -1
- package/skills/commerce/post-installation.md +197 -50
- package/skills/commerce/references/guest-access-security.md +1 -1
- package/skills/commerce/references/online-payments.md +26 -8
- package/src/commerce/admin/hooks/useAsync.js +1 -1
- package/src/commerce/admin/hooks/usePagedList.js +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +7 -6
- package/src/commerce/utils/index.js +5 -1
- package/src/commerce/utils/storefront.js +139 -0
|
@@ -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
|
|
|
@@ -46,6 +48,8 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
46
48
|
|
|
47
49
|
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
|
|
48
50
|
|
|
51
|
+
Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restricts how they get paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only" or "offline-only" is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. Explicit values win on re-runs too. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
|
|
52
|
+
|
|
49
53
|
| Mode | Body | Products created |
|
|
50
54
|
|---|---|---|
|
|
51
55
|
| **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | Yours — categories, ribbons, attributes, variants and all, in this one call |
|
|
@@ -68,6 +72,7 @@ Reference everything by **display name** — categories, ribbons, attributes and
|
|
|
68
72
|
await base44.functions.invoke("commerce/seed-store", {
|
|
69
73
|
store_name: "Aurora Threads",
|
|
70
74
|
currency: "EUR", // optional — defaults to USD
|
|
75
|
+
payment_methods: ["card"], // optional — enables ONLY these; omit to keep offline + card
|
|
71
76
|
products: [
|
|
72
77
|
{ // simple product
|
|
73
78
|
name: "Classic T-Shirt",
|
|
@@ -128,16 +133,142 @@ The response reports everything:
|
|
|
128
133
|
]
|
|
129
134
|
},
|
|
130
135
|
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
131
|
-
"currency": { "value": "EUR", "action": "created" }
|
|
136
|
+
"currency": { "value": "EUR", "action": "created" }, // "updated" | "unchanged" on re-runs; null when not passed
|
|
137
|
+
"payment_methods": { "enabled": ["card"], "disabled": ["offline"] } } // null when not passed
|
|
132
138
|
```
|
|
133
139
|
|
|
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.
|
|
140
|
+
**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
141
|
|
|
136
142
|
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
143
|
|
|
138
|
-
### 2.2 Payments
|
|
144
|
+
### 2.2 Payments — one file, any provider (Stripe as the reference)
|
|
145
|
+
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§2) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
|
|
149
|
+
|
|
150
|
+
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).
|
|
151
|
+
|
|
152
|
+
| Function | Backs |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `createCardPayment` | checkout + payment links (creates the hosted payment page) |
|
|
155
|
+
| `checkCardPaymentPaid` | confirmation — the return page, the webhook and the admin's "Check payment" all verify through it, against the provider's API |
|
|
156
|
+
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
157
|
+
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
158
|
+
|
|
159
|
+
**Stripe is the reference implementation** and ships ready to use — don't write the file yourself; overwrite `base44/shared/commerce/card-payment.ts` with this as-is, **as one whole-file write. Never `find_replace` into the stub**: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole). For another provider, implement the same four functions against its API instead (same shape: hosted page in, paid-check and refund by `reference`, event naming an order — rules in [`references/online-payments.md`](./references/online-payments.md)):
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
163
|
+
import Stripe from "npm:stripe@18";
|
|
164
|
+
import { HttpError } from "./auth.ts";
|
|
165
|
+
|
|
166
|
+
export interface CardPaymentPage {
|
|
167
|
+
url: string; // where the customer goes to pay
|
|
168
|
+
reference: string; // the provider's id for this payment, stored on the order
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const stripe = () => {
|
|
172
|
+
const key = Deno.env.get("STRIPE_SECRET_KEY");
|
|
173
|
+
if (!key) {
|
|
174
|
+
throw new HttpError(503, "Card payments are not configured — the STRIPE_SECRET_KEY secret is missing.", "no_card_payment_provider");
|
|
175
|
+
}
|
|
176
|
+
return new Stripe(key);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Stripe amounts are in minor units; these currencies have none.
|
|
180
|
+
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
181
|
+
const minorUnits = (amount: number, currency: string) =>
|
|
182
|
+
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
183
|
+
|
|
184
|
+
export async function createCardPayment(
|
|
185
|
+
_sr: any,
|
|
186
|
+
order: any,
|
|
187
|
+
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
188
|
+
): Promise<CardPaymentPage> {
|
|
189
|
+
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
190
|
+
// names the order when Stripe's event arrives — keep it on both objects.
|
|
191
|
+
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
192
|
+
const session = await stripe().checkout.sessions.create({
|
|
193
|
+
mode: "payment",
|
|
194
|
+
line_items: [{
|
|
195
|
+
quantity: 1,
|
|
196
|
+
price_data: {
|
|
197
|
+
currency: String(order.currency || "USD").toLowerCase(),
|
|
198
|
+
product_data: { name: `Order #${order.order_number}` },
|
|
199
|
+
unit_amount: minorUnits(order.total, order.currency),
|
|
200
|
+
},
|
|
201
|
+
}],
|
|
202
|
+
customer_email: opts.customerEmail || undefined,
|
|
203
|
+
metadata,
|
|
204
|
+
payment_intent_data: { metadata },
|
|
205
|
+
success_url: opts.successUrl,
|
|
206
|
+
cancel_url: opts.cancelUrl,
|
|
207
|
+
});
|
|
208
|
+
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
209
|
+
return { url: session.url, reference: session.id };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
213
|
+
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
214
|
+
// The payment must be for THIS order — stops a reference to some other
|
|
215
|
+
// (paid) session being replayed against a different order.
|
|
216
|
+
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
220
|
+
reference: string; amount: number; currency: string; reason?: string;
|
|
221
|
+
}): Promise<{ refund_id: string }> {
|
|
222
|
+
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
223
|
+
if (!session.payment_intent) {
|
|
224
|
+
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
225
|
+
}
|
|
226
|
+
const refund = await stripe().refunds.create({
|
|
227
|
+
payment_intent: String(session.payment_intent),
|
|
228
|
+
amount: minorUnits(opts.amount, opts.currency),
|
|
229
|
+
});
|
|
230
|
+
return { refund_id: refund.id };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
234
|
+
export interface CardWebhookEvent {
|
|
235
|
+
order_id: string;
|
|
236
|
+
order_key: string;
|
|
237
|
+
paid: boolean; // true only after signature verification — never from a raw body
|
|
238
|
+
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Stripe webhook events. The event body is never trusted: this only names
|
|
243
|
+
* the order (from the metadata createCardPayment attached) and returns
|
|
244
|
+
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
245
|
+
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
246
|
+
* signing secret is needed. (Optional fast path that skips the API
|
|
247
|
+
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
248
|
+
*/
|
|
249
|
+
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
250
|
+
let event: any;
|
|
251
|
+
try { event = JSON.parse(payload); } catch { return null; }
|
|
252
|
+
const metadata = event?.data?.object?.metadata;
|
|
253
|
+
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
254
|
+
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
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):
|
|
139
259
|
|
|
140
|
-
|
|
260
|
+
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.
|
|
261
|
+
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):
|
|
262
|
+
|
|
263
|
+
```js
|
|
264
|
+
await fetch("https://api.stripe.com/v1/webhook_endpoints", {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
267
|
+
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
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
272
|
|
|
142
273
|
---
|
|
143
274
|
|
|
@@ -145,31 +276,36 @@ The order side of payments is **already implemented** (checkout routing, confirm
|
|
|
145
276
|
|
|
146
277
|
No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. 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.
|
|
147
278
|
|
|
148
|
-
|
|
279
|
+
Build on the **shipped API client** — create it once and import that instance everywhere (wrapping it in a React context is fine; never a second copy):
|
|
149
280
|
|
|
150
281
|
```js
|
|
151
|
-
|
|
282
|
+
// src/lib/storefront.js
|
|
283
|
+
import { createStorefront } from "@/commerce/utils";
|
|
284
|
+
import { base44 } from "@/api/base44Client";
|
|
285
|
+
export const store = createStorefront(base44);
|
|
152
286
|
```
|
|
153
287
|
|
|
154
|
-
The
|
|
288
|
+
The client owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either**: 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). For anything beyond its methods, `store.inv(fn, payload)` unwraps the `{ success, data }` envelope (`res.data.data`).
|
|
289
|
+
|
|
290
|
+
Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
|
|
155
291
|
|
|
156
292
|
### 3.1 Product list
|
|
157
293
|
|
|
158
294
|
```js
|
|
159
|
-
const info = await
|
|
295
|
+
const info = await store.getStoreInfo(); // cached — call it wherever needed
|
|
160
296
|
// info.settings → { store_name, currency, weight_unit, … } — format money with
|
|
161
297
|
// Intl.NumberFormat(undefined, { style: "currency", currency: info.settings.currency })
|
|
162
|
-
// info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
|
|
298
|
+
// info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout;
|
|
299
|
+
// this call is its ONLY source (it is never on the cart view)
|
|
163
300
|
// info.countries / info.currencies → static tables for address forms and money display
|
|
164
301
|
|
|
165
|
-
const { products, page, per_page, has_next } = await
|
|
166
|
-
action: "list-products",
|
|
302
|
+
const { products, page, per_page, has_next } = await store.listProducts({
|
|
167
303
|
page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
|
|
168
304
|
sort: "-created_date", // min_price, max_price, in_stock_only
|
|
169
305
|
}); // sort: -created_date | name | price | -price | popularity | rating
|
|
170
306
|
```
|
|
171
307
|
|
|
172
|
-
Each row is a full product record — for a card use `name`, `images[0]?.src
|
|
308
|
+
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()` (a tree via `parent_id`). That is the whole card — no other call or reference needed for the list view.
|
|
173
309
|
|
|
174
310
|
**Carry forward:** each card links to the product page by **`slug`**.
|
|
175
311
|
|
|
@@ -177,7 +313,7 @@ Each row is a full product record — for a card use `name`, `images[0]?.src`, `
|
|
|
177
313
|
|
|
178
314
|
```js
|
|
179
315
|
const { product, variations, categories, ribbons, reviews } =
|
|
180
|
-
await
|
|
316
|
+
await store.getProduct(slug); // or store.getProduct({ id })
|
|
181
317
|
|
|
182
318
|
// One selector PER product.attributes[] entry — never a flat list of variations.
|
|
183
319
|
import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
|
|
@@ -193,43 +329,43 @@ const view = resolveSelection(product, variations, selection);
|
|
|
193
329
|
// view.addToCart → { product_id, variation_id } — null until the selection resolves
|
|
194
330
|
```
|
|
195
331
|
|
|
196
|
-
Add to cart —
|
|
332
|
+
Add to cart — the cart bootstraps itself (a missing, stale or expired token starts a fresh one) and the client does all the token bookkeeping:
|
|
197
333
|
|
|
198
334
|
```js
|
|
199
|
-
const cart = await
|
|
200
|
-
action: "add-item",
|
|
201
|
-
cart_token: localStorage.getItem("cart_token") || undefined, // fine if absent/stale
|
|
202
|
-
...view.addToCart,
|
|
203
|
-
quantity: 1,
|
|
204
|
-
});
|
|
205
|
-
localStorage.setItem("cart_token", cart.cart_token); // ALWAYS — the token may be a new cart's
|
|
335
|
+
const cart = await store.addItem({ ...view.addToCart, quantity: 1 });
|
|
206
336
|
```
|
|
207
337
|
|
|
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.
|
|
338
|
+
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.
|
|
339
|
+
|
|
340
|
+
**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
341
|
|
|
210
|
-
**Carry forward:**
|
|
342
|
+
**Carry forward:** nothing — the client keeps the `cart_token`.
|
|
211
343
|
|
|
212
344
|
### 3.3 Cart
|
|
213
345
|
|
|
214
346
|
**Every cart action returns the same full priced view**, so re-render from whatever the last call returned — no separate refresh:
|
|
215
347
|
|
|
216
348
|
```js
|
|
217
|
-
let cart = await
|
|
349
|
+
let cart = await store.getCart(); // null → no cart yet (an expired token self-clears)
|
|
218
350
|
// cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
|
|
219
351
|
// cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
|
|
220
352
|
// cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
353
|
+
//
|
|
354
|
+
// NOT in the cart view: payment_gateways (store.getStoreInfo() ONLY — cart.payment_gateways
|
|
355
|
+
// is always undefined), the product catalog (listProducts/getProduct), countries/currencies
|
|
356
|
+
// (getStoreInfo). Never dot into the cart for any of those.
|
|
357
|
+
|
|
358
|
+
cart = await store.updateItem(item_key, quantity); // ≤0 removes
|
|
359
|
+
cart = await store.removeItem(item_key);
|
|
360
|
+
cart = await store.applyCoupon(code);
|
|
225
361
|
```
|
|
226
362
|
|
|
227
363
|
Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`). **Call `set-shipping-address` the moment the customer provides an address** — every shipping option and cost is recalculated by that call (never reuse a list fetched earlier), and an address the store doesn't ship to **fails right there** with `400 shipping_not_available`, so the address form is where you surface it:
|
|
228
364
|
|
|
229
365
|
```js
|
|
230
366
|
// as soon as the address is entered — this is what (re)calculates shipping options + cost
|
|
231
|
-
cart = await
|
|
232
|
-
|
|
367
|
+
cart = await store.setShippingAddress({ country, state, postcode, city });
|
|
368
|
+
// 400 shipping_not_available → show it on the address form
|
|
233
369
|
// cart.chosen_shipping_method is the rate's ID (a string) — to display it, look
|
|
234
370
|
// it up: cart.available_shipping_methods.find(m => m.id === cart.chosen_shipping_method)
|
|
235
371
|
// and render that entry's title + cost. Never render the id itself.
|
|
@@ -239,11 +375,10 @@ switch (cart.shipping_status) {
|
|
|
239
375
|
case "chosen": break; // customer's earlier choice still valid
|
|
240
376
|
case "choice_required": // several options — MUST render cart.available_shipping_methods
|
|
241
377
|
// [{ id, title, cost }] as a picker, then send the customer's pick:
|
|
242
|
-
cart = await
|
|
243
|
-
method_id: picked.id }); // the entry's id, not its method_id type
|
|
378
|
+
cart = await store.chooseShippingMethod(picked.id); // the entry's id, not its method_id type
|
|
244
379
|
break;
|
|
245
380
|
case "missing_address": break; // several zones, no address yet — shipping cost is NOT
|
|
246
|
-
// calculated; collect the address and call
|
|
381
|
+
// calculated; collect the address and call setShippingAddress
|
|
247
382
|
case "not_needed": break; // fully virtual cart
|
|
248
383
|
}
|
|
249
384
|
```
|
|
@@ -254,12 +389,17 @@ A store with exactly **one shipping zone** shows its options (and, auto-selected
|
|
|
254
389
|
|
|
255
390
|
### 3.4 Checkout & order-received
|
|
256
391
|
|
|
257
|
-
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
|
|
392
|
+
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 — every gateway the admin has **enabled**:
|
|
258
393
|
|
|
259
394
|
```js
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
const
|
|
395
|
+
// card payments = ONE file to implement — §2.2 above has the complete
|
|
396
|
+
// Stripe implementation to paste; no other reading needed
|
|
397
|
+
const { payment_gateways: gateways } = await store.getStoreInfo();
|
|
398
|
+
// ⚠ get-store-info is the ONLY source of payment_gateways — the cart view does
|
|
399
|
+
// NOT include them (cart.payment_gateways is always undefined, which reads as
|
|
400
|
+
// "no methods" and dead-disables the place-order button). If a context caches
|
|
401
|
+
// store-info, read it from there — never from the cart object.
|
|
402
|
+
// gateways → [{ slug, title, description, online }] — admin-owned data
|
|
263
403
|
// several → render a picker using the admin's title/description as the labels
|
|
264
404
|
// exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
|
|
265
405
|
// none → checkout cannot complete — say so instead of rendering a dead button
|
|
@@ -271,12 +411,11 @@ While the store has no card provider implemented, picking the card gateway fails
|
|
|
271
411
|
`online: true` marks the card/redirect gateway; every other gateway is manual reconciliation. Then one call places the order:
|
|
272
412
|
|
|
273
413
|
```js
|
|
274
|
-
const res = await
|
|
275
|
-
action: "place-order", cart_token,
|
|
414
|
+
const res = await store.placeOrder({
|
|
276
415
|
payment_method, // the slug chosen above
|
|
277
416
|
billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
|
|
278
|
-
// shipping: { … } if it differs from billing; customer_note
|
|
279
|
-
});
|
|
417
|
+
// shipping: { … } if it differs from billing; customer_note?
|
|
418
|
+
}); // token + return_url handled by the client; the cart is consumed
|
|
280
419
|
// res → { order_id, order_number, order_key, status, totals, order,
|
|
281
420
|
// payment_instructions, // manual gateways: { description, account_details } — render them
|
|
282
421
|
// payment } // card: { status: "requires_payment", checkout_url, … } | null
|
|
@@ -289,13 +428,8 @@ Every payment link returns to **`/order-received`** — the page from §1 step 3
|
|
|
289
428
|
|
|
290
429
|
```js
|
|
291
430
|
// GET /order-received?order_id=…&order_key=…&payment=success|cancel
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
action: "complete-return",
|
|
295
|
-
order_id: params.get("order_id"), order_key: params.get("order_key"),
|
|
296
|
-
payment: params.get("payment"), // only a hint — the server verifies with the provider
|
|
297
|
-
return_url: window.location.origin,
|
|
298
|
-
});
|
|
431
|
+
const { state, order, payment_link, payment_instructions } = await store.completeReturn();
|
|
432
|
+
// (reads the URL params itself; ?payment= is only a hint — the server verifies with the provider)
|
|
299
433
|
// state === "paid" → thank-you + order summary (order is now marked paid)
|
|
300
434
|
// state === "unpaid" → card order: offer payment_link.url to pay now;
|
|
301
435
|
// manual order: render payment_instructions ({ description, account_details })
|
|
@@ -308,6 +442,19 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
|
|
|
308
442
|
|
|
309
443
|
---
|
|
310
444
|
|
|
311
|
-
## 4.
|
|
445
|
+
## 4. Done when
|
|
446
|
+
|
|
447
|
+
Post-installation is complete when every line below holds — verify against this list instead of re-reading docs:
|
|
448
|
+
|
|
449
|
+
- [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
|
|
450
|
+
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the user restricted payment methods, `payment_methods` was passed in that same call.
|
|
451
|
+
- [ ] Product list renders from `store.listProducts` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
|
|
452
|
+
- [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
|
|
453
|
+
- [ ] The storefront talks to the API through **one `createStorefront` instance** — no hand-rolled `cart_token` handling, and `payment_gateways` read from `getStoreInfo()` only, never off a cart.
|
|
454
|
+
- [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
|
|
455
|
+
- [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway disabled (seed `payment_methods` without `"card"`).
|
|
456
|
+
- [ ] **Verified by driving a real test order end to end** — add to cart → address → shipping → `place-order` → `/order-received` renders the right state — then the test order cancelled/deleted in the admin. Placing and cleaning up a test order is routine install verification, not a destructive action. A button existing is **not** evidence the flow works; only the driven flow is.
|
|
457
|
+
|
|
458
|
+
## 5. Next
|
|
312
459
|
|
|
313
460
|
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,49 @@
|
|
|
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
|
|
|
29
|
+
- **Replace `card-payment.ts` whole** — one write of the full new file, never a `find_replace` into the shipped stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"); the fix is always to rewrite the file whole.
|
|
25
30
|
- **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
|
-
- **
|
|
31
|
+
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off.
|
|
32
|
+
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. It should also check the payment **names this order** (compare the payment's metadata `order_id` to `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
33
|
+
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` + `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
34
|
+
- **`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
35
|
- **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
36
|
- The shared helpers in `base44/shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
29
37
|
|
|
38
|
+
## `parseWebhook` in depth
|
|
39
|
+
|
|
40
|
+
`parseWebhook(req, payload)` lives in `card-payment.ts` with the other three; `commerce/payment-webhook` (the function file) is premade and calls it with the raw request and the raw body — the exact bytes, so signature schemes work. It returns `CardWebhookEvent | null`:
|
|
41
|
+
|
|
42
|
+
- **The simple, secure default — the nudge**: parse the event, read the `order_id`/`order_key` metadata `createCardPayment` attached, return `{ order_id, order_key, paid: false }`. No signing secret; the premade flow verifies via `checkCardPaymentPaid` against the provider's API, so forgery is impossible by construction. This is what the Stripe reference implementation does.
|
|
43
|
+
- **The signature-verified fast path** (optional): verify the provider's signature over the raw `payload` bytes (Stripe: `constructEventAsync` with a webhook signing secret) and return `paid: true` with the event's `reference` for a verified successful payment; the premade code then trusts it without the API round-trip. `paid: true` from an unverified body is the one way to break this design — never do it.
|
|
44
|
+
- Return **`null`** for events that aren't about a payment for one of this store's orders; the function answers 200 so the provider doesn't retry.
|
|
45
|
+
|
|
46
|
+
Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
|
|
47
|
+
|
|
30
48
|
## Storefront requirements (unchanged by any of this)
|
|
31
49
|
|
|
32
50
|
- Redirect to `payment.checkout_url` when `place-order` returns `payment.status === "requires_payment"`.
|
|
@@ -27,7 +27,7 @@ export default function useAsync(fn, deps = []) {
|
|
|
27
27
|
|
|
28
28
|
useEffect(() => {
|
|
29
29
|
run();
|
|
30
|
-
// eslint-disable-next-line -- deps intentionally
|
|
30
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- caller-supplied deps; `run` is stable and intentionally omitted
|
|
31
31
|
}, deps);
|
|
32
32
|
|
|
33
33
|
// Note: `refetch` deliberately takes no arguments, so it stays safe to pass
|
|
@@ -55,7 +55,7 @@ export default function usePagedList(fetcher, { pageSize = 20, initialSort = "-c
|
|
|
55
55
|
}
|
|
56
56
|
if (page !== 0) setPage(0);
|
|
57
57
|
else load(0, sort);
|
|
58
|
-
// eslint-disable-next-line --
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on depsKey alone; adding page/sort/load would double-fetch
|
|
59
59
|
}, [depsKey]);
|
|
60
60
|
|
|
61
61
|
useEffect(() => {
|
|
@@ -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>
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* template, but this logic does — it is the part that is easy to get subtly
|
|
7
7
|
* wrong.
|
|
8
8
|
*
|
|
9
|
-
* import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
9
|
+
* import { createStorefront, resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
10
10
|
*
|
|
11
|
+
* - `storefront.js` — the API client: cart_token lifecycle, cached store-info
|
|
12
|
+
* (payment_gateways live there, never on the cart view), catalog/cart/
|
|
13
|
+
* checkout/return-page calls. Create ONE instance and import it everywhere.
|
|
11
14
|
* - `variants.js` — variant selection: map attribute selections (Size, Color)
|
|
12
15
|
* to a `ProductVariation` and back, per-option availability, variant price
|
|
13
16
|
* ranges. See `.agents/skills/commerce/references/storefront-product-page.md`.
|
|
@@ -15,5 +18,6 @@
|
|
|
15
18
|
* "Free shipping over €150" copy states a configured rule, not an invented
|
|
16
19
|
* number. See `.agents/skills/commerce/docs/api-storefront.md`.
|
|
17
20
|
*/
|
|
21
|
+
export * from "./storefront.js";
|
|
18
22
|
export * from "./variants.js";
|
|
19
23
|
export * from "./shipping-promos.js";
|