@base44/app-plugin-commerce 0.1.20 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -22
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +198 -0
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +117 -51
- package/skills/commerce/docs/api-admin.md +89 -28
- package/skills/commerce/docs/api-storefront.md +113 -126
- package/skills/commerce/docs/entities.md +137 -0
- package/skills/commerce/install/01-install.md +101 -0
- package/skills/commerce/install/02-storefront.md +444 -0
- package/skills/commerce/install/03-data.md +162 -0
- package/skills/commerce/references/admin-product-form.md +10 -0
- package/skills/commerce/references/catalog-rendering.md +110 -0
- package/skills/commerce/references/emails.md +49 -12
- package/skills/commerce/references/guest-access-security.md +18 -5
- package/skills/commerce/references/online-payments.md +49 -149
- package/skills/commerce/references/operations.md +52 -0
- package/skills/commerce/references/reviews.md +31 -16
- package/skills/commerce/references/shipping-and-tax.md +110 -0
- package/skills/commerce/references/store-admin-agent.md +21 -0
- package/skills/commerce/references/store-settings.md +49 -0
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/index.js +74 -24
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +221 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +295 -0
- package/src/commerce/storefront/useProductGallery.js +74 -0
- package/src/commerce/storefront/useProductList.js +153 -0
- package/src/commerce/storefront/useProductPrice.js +58 -0
- package/src/commerce/storefront/useProductReviews.js +242 -0
- package/src/commerce/storefront/useStorefrontSeo.js +204 -0
- package/src/commerce/storefront/useTotalsLines.js +109 -0
- package/src/commerce/utils/address-spec.js +89 -0
- package/src/commerce/utils/images.js +45 -0
- package/src/commerce/utils/index.js +22 -7
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +58 -3
- package/skills/commerce/installation-guidelines.md +0 -93
- package/skills/commerce/post-installation.md +0 -496
- package/skills/commerce/references/limits-and-performance.md +0 -16
- package/skills/commerce/references/media-and-downloads.md +0 -4
- package/skills/commerce/references/product-render.md +0 -89
- package/skills/commerce/references/scheduled-work.md +0 -19
- package/skills/commerce/references/storefront-product-page.md +0 -83
- package/skills/commerce/references/webhooks.md +0 -10
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
stage: reference
|
|
3
|
+
read_when: "You are asking which fields a card or a product page can actually show, or hit a variant edge case (unavailable combinations, attributes with no variations, price ranges, linkable selections)."
|
|
4
|
+
skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../install/02-storefront.md covers the happy path."
|
|
5
|
+
forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
|
|
6
|
+
carry_forward:
|
|
7
|
+
- "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
|
|
8
|
+
- "A variant parent's `price` is a FROM price — render it through productPrice/useProductPrice, never as the price."
|
|
9
|
+
- "Product images are objects `{src, name, alt}` and the array can be empty — go through productImages/normalizeImage and render a placeholder."
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Rendering the catalog: listing and product page
|
|
13
|
+
|
|
14
|
+
Both storefront surfaces render the same catalog record from a different call:
|
|
15
|
+
|
|
16
|
+
| View | Call | Hook | Returns |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| Listing / grid / search / ribbon & category pages | `commerce/storefront-catalog` `list-products` | `useProductList` | `{ products: [row…], page, per_page, has_next }` |
|
|
19
|
+
| Product page | `commerce/storefront-catalog` `get-product` | `useProduct` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
|
|
20
|
+
|
|
21
|
+
Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). You choose what belongs in each view — but you can only render what the call returns, and §1 is that boundary.
|
|
22
|
+
|
|
23
|
+
## 1. Field availability
|
|
24
|
+
|
|
25
|
+
A listing **row** is the product record itself (minus paywalled fields) plus resolved `ribbons`. `get-product` adds everything that needs a second read.
|
|
26
|
+
|
|
27
|
+
| Data | `list-products` row | `get-product` | Notes |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| `id`, `name`, `slug`, `status` | ✅ | ✅ | No `type` field — see §2 |
|
|
30
|
+
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a *from* price — §2 |
|
|
31
|
+
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
|
|
32
|
+
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
|
+
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
34
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec-table rows) |
|
|
35
|
+
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
|
|
36
|
+
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
|
+
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
38
|
+
| **`reviews`** (paged items + aggregates) | ❌ | ✅ | Rows still carry the aggregate numbers |
|
|
39
|
+
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
40
|
+
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — reached only through `commerce/storefront-account` `get-download` |
|
|
41
|
+
|
|
42
|
+
## 2. Two shapes, and the three rules the code now owns
|
|
43
|
+
|
|
44
|
+
There is **no product `type` field**. `attributes[]` tells the shapes apart:
|
|
45
|
+
|
|
46
|
+
| Shape | Test | Card | Product page |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| **Single item** | `attributes` is empty | price, and Add to cart straight from the card if you want one | one Add to cart — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
|
|
49
|
+
| **Sells variants** | `attributes` is non-empty | price **range or "From €19"**, never selectors — link through | one control per axis — §5 |
|
|
50
|
+
|
|
51
|
+
A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, with no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident (§5 covers rendering that state).
|
|
52
|
+
|
|
53
|
+
Three rules used to be prose here and are now enforced by exports — use them and they can't drift between views:
|
|
54
|
+
|
|
55
|
+
- **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
|
|
56
|
+
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
|
|
57
|
+
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is a spec table — never a selector, never a ribbon.
|
|
58
|
+
|
|
59
|
+
## 3. What each view renders
|
|
60
|
+
|
|
61
|
+
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page — like every other piece of storefront UI, the card is entirely yours to design.
|
|
62
|
+
|
|
63
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
64
|
+
|
|
65
|
+
## 4. Ribbons — in **both** views
|
|
66
|
+
|
|
67
|
+
Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
|
|
68
|
+
|
|
69
|
+
- **Listing rows carry resolved `ribbons`**, so a card renders them with no extra call; the product page renders the top-level `ribbons` near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and an empty array means render nothing — never a dangling "Ribbons:" label.
|
|
70
|
+
- **Every ribbon links to a filtered listing** — `list-products` with `ribbon_id`, never a dead label. A ribbon has no slug, so key the URL on its id (`/shop?ribbon=<id>`) to keep the page shareable across reloads.
|
|
71
|
+
- **Offer ribbons as a filter** from `useRibbons()` / `list-ribbons`, which hides ribbons no published product carries and gives a `count` for labels like "Gift (12)". `ribbon_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
72
|
+
- Ribbons are not breadcrumbs and never variant options — a size or colour is an `attribute`.
|
|
73
|
+
|
|
74
|
+
## 5. Variant selection
|
|
75
|
+
|
|
76
|
+
The one interaction agents reliably get wrong. `variantAxes(view, pick)` hands you the render-ready model that encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control — buttons, swatches, dropdowns — and call `o.pick()` on select.
|
|
77
|
+
|
|
78
|
+
Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js` — `defaultSelection` → `selectOption` on a click → `resolveSelection` for the view. Every `product.attributes[]` entry is an **axis** in `position` order; every `variations[]` record is one combination. Bind the UI to `view`, not to `product.*`, or a selection changes nothing:
|
|
79
|
+
|
|
80
|
+
| `view` field | Binds to |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `axes` | the controls: `[{ key, name, attribute_id, options[] }]`, dead options already dropped |
|
|
83
|
+
| `availability[axisKey][option]` | `"available"` / `"out_of_stock"` / `"unavailable"` (constants `OPTION_*`) |
|
|
84
|
+
| `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback; `display.image` is normalized or `null` |
|
|
85
|
+
| `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `useProductPrice(view)` |
|
|
86
|
+
| `complete` / `missingAxes` | the button label: `Select a ${view.missingAxes[0]?.name}` |
|
|
87
|
+
| `purchasable` / `addToCart` | gate **Add to cart** on `purchasable`, send `addToCart` (`{product_id, variation_id}`, or `null`) |
|
|
88
|
+
| `variation` / `candidates` / `isVariable` / `selection` | the record itself, the variations still reachable, and the state the selector renders from |
|
|
89
|
+
|
|
90
|
+
Three decisions the helpers can't make for you:
|
|
91
|
+
|
|
92
|
+
- **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → visible and labelled. `onbackorder` counts as available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
|
|
93
|
+
- **Incomplete selection.** Show the range from `useProductPrice(view)` — never `€0`, never the bare parent price — and keep Add to cart disabled with a hint at what's missing.
|
|
94
|
+
- **Linkability.** `useProduct` mirrors the selection into the URL (`?color=Ivory`) and hydrates from it, so a variant is shareable and survives a reload; `selectionToParams`/`selectionFromParams` do a custom round-trip, and `selectionForVariation` hydrates controls from a cart line or an `?variation=` link.
|
|
95
|
+
|
|
96
|
+
**Attributes but no usable variations** — what attaching an attribute and stopping leaves behind — deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
|
|
97
|
+
|
|
98
|
+
**Add to cart** goes through `useAddToCart()`, which never throws and maps the codes: `variation_required` (a page bug — empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → `shouldReload: true`, refetch). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
|
|
99
|
+
|
|
100
|
+
## 6. Adding a `get-product`-only field to the listing
|
|
101
|
+
|
|
102
|
+
If the store needs something a row doesn't carry — resolved categories are the common one — resolve it **in the listing call**, never with `get-product` per card (N requests for one grid). `list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`withRibbons` reads the taxonomy **once per request** and maps ids onto the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to fields the UI renders; a listing payload is served on every browse.
|
|
109
|
+
|
|
110
|
+
Don't add `variations` to every row — the heaviest read in the catalog, and a grid doesn't need it; if cards must show true ranges, precompute `price_min`/`price_max`. And don't relax entity RLS to read the catalog from the client — it is admin-only by design ([`guest-access-security.md`](./guest-access-security.md)); widen the function's response instead.
|
|
@@ -1,14 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
stage: reference
|
|
3
|
+
read_when: "You are changing which transactional emails go out, their copy, who receives them, or investigating a mail that didn't arrive."
|
|
4
|
+
skip_when: "The store's default emails are fine — all ten order emails are wired and enabled out of the box."
|
|
5
|
+
forget_when: "The email in question sends to the intended recipient, or its EmailLog row explains why it didn't."
|
|
6
|
+
carry_forward:
|
|
7
|
+
- "Per-type email overrides live at the TOP level of the emails settings group, keyed by type id (emails.new_order.enabled) — never nested."
|
|
8
|
+
- "emails.store_name is both the {store_name} in subjects and the sender name, and it is the public shop name on get-store-info."
|
|
9
|
+
---
|
|
10
|
+
|
|
1
11
|
# Emails
|
|
2
12
|
|
|
3
|
-
Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
13
|
+
Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`.
|
|
14
|
+
|
|
15
|
+
## Email types
|
|
16
|
+
|
|
17
|
+
Ten order emails are wired to the lifecycle (see the side-effect matrix in [`../docs/api-admin.md`](../docs/api-admin.md)): `new_order`, `cancelled_order`, `failed_order`, `on_hold_order`, `processing_order`, `completed_order`, `refunded_order`, `partial_refund`, `customer_invoice`, `customer_note`. `reset_password` and `new_account` are handled by **Base44 auth**, not this kit.
|
|
18
|
+
|
|
19
|
+
Two stock notifications — `low_stock`, `out_of_stock` — sit in the same Settings → Emails list. Their subject and body are generated (product name, SKU, remaining stock), so they have no subject/heading overrides.
|
|
20
|
+
|
|
21
|
+
`emails_sent[]` on each order dedupes lifecycle emails, so a re-entered status won't re-send.
|
|
22
|
+
|
|
23
|
+
## Per-type overrides
|
|
24
|
+
|
|
25
|
+
Enable/subject/heading/recipient/additional_content overrides live at the **top level** of the `emails` StoreSettings group, keyed by type id (`emails.new_order.enabled`) — that is the path `shared/commerce/emails.ts` reads. Editable in Settings → Emails; blank = built-in default. **Don't nest them under a sub-object: the backend won't see them.** The stock types take `enabled` and `recipient` only.
|
|
26
|
+
|
|
27
|
+
## Recipients
|
|
28
|
+
|
|
29
|
+
- **Admin notifications** (`new_order`, `cancelled_order`, `failed_order`) resolve in three steps: the per-type `recipient` if it holds an address, else `emails.admin_recipients`, else — at send time — **the app's users with `role: "admin"`** (`sr.entities.User.filter({role:"admin"})`, memoized per isolate). Both settings accept a comma-separated list and drop blank entries, so a cleared per-type override falls back rather than sending to an empty address.
|
|
30
|
+
- Nothing is seeded: a fresh store notifies its admin users until someone sets explicit recipients, and promoting an admin is enough to add them. Only when there is also no admin user does the email go nowhere — logged with `success: false` and an `error` naming what was empty. Settings → Emails shows the addresses actually in effect (via `commerce/admin-tools` `admin-email-recipients`).
|
|
31
|
+
- **Stock notifications** resolve identically: per-type `recipient`, else `emails.admin_recipients`, else the app's admin users.
|
|
32
|
+
|
|
33
|
+
## The store name
|
|
34
|
+
|
|
35
|
+
`emails.store_name` is one setting doing three jobs: the `{store_name}` in subjects/headings, the `SendEmail` sender name, and — via `storefrontSafeSettings()` — the public shop name published as `settings.store_name` on `get-store-info`. `commerce/seed-store` fills it from the `store_name` it is passed; Settings → Emails is where a merchant changes it.
|
|
36
|
+
|
|
37
|
+
It seeds **blank on purpose**: a blank name means `SendEmail` is called without `from_name`, in which case **Base44 sends as the app's name** (verified: an app named *Canvas* with it blank delivered mail from "Canvas"). So the sender is the app name unless the store overrides it — one place to rename, and no hardcoded default.
|
|
38
|
+
|
|
39
|
+
That fallback does **not** reach subjects or headings: those are rendered by this kit, which has no access to the app name. Subjects and headings tolerate a blank store name — `{store_name}` substitution drops empty brackets and collapses the gap — so an unnamed store sends `New order #1002` rather than `[Canvas]: New order #1002`, and never `[]: New order #1002`. Set the store name if you want it in the subject line.
|
|
40
|
+
|
|
41
|
+
## Deliverability
|
|
42
|
+
|
|
43
|
+
SPF/DKIM and the sending **address** depend on your Base44 email configuration — the store only sets the sender *name*.
|
|
44
|
+
|
|
45
|
+
## The email log
|
|
46
|
+
|
|
47
|
+
Every send is recorded in `commerce.EmailLog`: one row per address per attempt.
|
|
48
|
+
|
|
49
|
+
- `target` is `admin` or `customer` — the same split the Recipient column shows in Settings → Emails (stock notifications count as `admin`).
|
|
50
|
+
- `order_id` is the id **users are shown**: the `order_number` from the admin Orders list and every email subject (`#1023` → `1023`). The internal Order record id is in `order_record_id`, which is what the admin routes on.
|
|
51
|
+
- Rows written before these fields existed still carry the record id in `order_id` and have no `target`; they are not backfilled.
|
|
@@ -1,18 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
stage: reference
|
|
3
|
+
read_when: "You are adding anything to a storefront function, writing a new one, or deciding what a guest may reach — wishlists, loyalty, saved details, subscriptions, tickets."
|
|
4
|
+
skip_when: "You are only calling the shipped functions from UI you build; they already enforce their own guards."
|
|
5
|
+
forget_when: "The code you added derives identity from the session, gates owned data on requireUser, and returns nothing about anyone else."
|
|
6
|
+
carry_forward:
|
|
7
|
+
- "Identity comes from the session, never the request body: getCallerUser/requireUser/ownsEmail in shared/commerce/auth.ts."
|
|
8
|
+
- "All entities are admin-only RLS; storefronts read through commerce/storefront-* functions (service role) — never relax a catalog entity's read rule."
|
|
9
|
+
- "cart_token and order_key are bearer credentials for ONE record each."
|
|
10
|
+
---
|
|
11
|
+
|
|
1
12
|
# Guest access & security
|
|
2
13
|
|
|
3
|
-
|
|
14
|
+
## The identity model
|
|
15
|
+
|
|
16
|
+
- Storefront functions are **public** (anonymous invocation); confirm your app allows unauthenticated function calls. Checkout accepts guests unconditionally — there is no store setting that forces login. A login-required store enforces that in the shopfront you build, and, if it must not be bypassable, by adding the check to `commerce/storefront-checkout` `place-order`.
|
|
4
17
|
- **All entities are admin-only RLS**, including the catalog (Product, ProductVariation, categories, tax/shipping config, payment gateways). Storefronts read the catalog through `commerce/storefront-catalog` (service role), never by querying entities directly with the client SDK — a direct read from a non-admin is rejected by the backend. This is deliberate: a world-readable catalog row exposes more than a shopper should see (`downloads[].file_url`, draft/hidden products, stock and sales internals, gateway `settings` such as the offline gateway's bank details). Do **not** relax any catalog entity's read RLS to `true`; if a shopfront needs a field, surface it through the storefront function instead.
|
|
5
18
|
- The storefront catalog function strips the paywalled fields (`downloads`, `download_limit`, `download_expiry`) from every product/variation it returns. Downloadable files are reached only through `commerce/storefront-account` `get-download`, which enforces ownership, download limits, expiry and short-lived signed URLs — keep that the only path to a `file_url`.
|
|
6
19
|
- `cart_token` and `order_key` are **bearer credentials** — possession grants access to that cart/order. Always serve over HTTPS; don't log them; treat them like secrets.
|
|
7
20
|
- Carts and orders have admin-only RLS; customers never touch those entities directly — all access is mediated by `commerce/storefront-*` functions using the service role after verifying the caller.
|
|
8
|
-
- The admin side has three enforcement layers
|
|
21
|
+
- The admin side has three enforcement layers; never weaken them ([`../install/01-install.md`](../install/01-install.md)).
|
|
9
22
|
|
|
10
23
|
## Rules for anything you add
|
|
11
24
|
|
|
12
25
|
The storefront functions run as the service role, so RLS is not protecting the caller from itself — these guards are. Keep them when you extend a function, and follow them in a new one.
|
|
13
26
|
|
|
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
|
|
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
|
|
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.
|
|
27
|
+
- **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 a signed-in caller's session email always beats a `reviewer_email` in the payload, 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.
|
|
28
|
+
- **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 wins.)
|
|
17
29
|
- **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
30
|
- **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.
|
|
31
|
+
- **Never mark an order paid from a request.** Only the payment provider can say so, through `confirmCardPayment()`/`checkCardPaymentPaid` — no `paid: true` flag, no client `transaction_id`, no `?payment=success`, and never a new "confirm" endpoint ([`online-payments.md`](./online-payments.md)).
|
|
@@ -1,172 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
stage: reference
|
|
3
|
+
read_when: "The store opted into card payments and you are wiring the provider NOW."
|
|
4
|
+
skip_when: "The store takes offline payments (the default) — nothing here applies; the decision itself lives in ../install/03-data.md."
|
|
5
|
+
forget_when: "A test payment reaches /order-received as paid, or the provider file is copied and the `card` gateway is enabled."
|
|
6
|
+
carry_forward:
|
|
7
|
+
- "Wiring a provider = replace shared/commerce/card-payment.ts WHOLE (copy the shipped card-payment.<provider>.ts over it) — a partial edit breaks every commerce function's deploy with duplicate exports."
|
|
8
|
+
- "Stripe: the shipped card-payment.stripe.ts is used as-is once the app is connected to Stripe — connecting is outside the kit. Enabling the `card` gateway is the seed call."
|
|
9
|
+
- "Only the provider can say an order is paid: confirmation always goes through checkCardPaymentPaid against the provider's API."
|
|
10
|
+
---
|
|
11
|
+
|
|
1
12
|
# Online (card) payments
|
|
2
13
|
|
|
3
|
-
The
|
|
14
|
+
The kit ships a **Credit card** checkout option (`commerce.PaymentGateway` slug `card`) with everything around it 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
15
|
|
|
5
|
-
What it does **not** ship
|
|
16
|
+
What it does **not** ship is a live provider. Wiring one means **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
17
|
|
|
7
|
-
|
|
18
|
+
Card payments are **off by default** and are the last thing to add, never the first — the rule, the timing and the decision table live in [`../install/03-data.md`](../install/03-data.md). Everything below assumes that decision is made.
|
|
8
19
|
|
|
9
20
|
| Function | Backs |
|
|
10
21
|
|---|---|
|
|
11
|
-
| `createCardPayment` | checkout + payment links:
|
|
12
|
-
| `checkCardPaymentPaid` | confirmation: ask the provider whether `reference` was paid **and is the payment for this order** —
|
|
13
|
-
| `refundCardPayment` | admin refunds through the provider (optional — leave the stub
|
|
14
|
-
| `parseWebhook` |
|
|
15
|
-
|
|
16
|
-
**Wiring a payment provider?** Whichever one the store chose, the job is the same: implement those four functions against its API, following the rules on this page. As a worked example, [a complete Stripe implementation](#reference-implementation--stripe) sits at the bottom of the page — with its secret and webhook-endpoint steps — usable as-is if Stripe happens to be the provider, and as a model of the four contracts if it isn't. It is a reference for one common choice, **not a bundled provider**: no provider ships with the template.
|
|
22
|
+
| `createCardPayment(sr, order, {successUrl, cancelUrl, customerEmail})` | checkout + payment links: a hosted page for `order.total`, returning `{ url, reference }`, with `order.id`/`order.order_key` on the payment's metadata |
|
|
23
|
+
| `checkCardPaymentPaid(sr, order, reference)` | confirmation: ask the provider whether `reference` was paid **and is the payment for this order** — the return page, the webhook and the admin's "Check payment" |
|
|
24
|
+
| `refundCardPayment(sr, order, {reference, amount, currency, reason})` | admin refunds through the provider (optional — leave the stub, refunds stay manual) |
|
|
25
|
+
| `parseWebhook(req, payload)` | name the order a provider event is about; vouch `paid: true` **only** after verifying the signature over the raw body |
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
## Stripe: copy the shipped file
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
The kit ships a complete, ready-to-use Stripe implementation at **`base44/shared/commerce/card-payment.stripe.ts`**. There is nothing to transcribe and nothing to fill in — wire it by copying it over the stub:
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
```js
|
|
32
|
+
fs.copyFileSync(
|
|
33
|
+
"base44/shared/commerce/card-payment.stripe.ts",
|
|
34
|
+
"base44/shared/commerce/card-payment.ts",
|
|
35
|
+
);
|
|
36
|
+
```
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
…and **enable the gateway**: `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. The `card` row is seeded off, so that call is what makes card payment visible at checkout — the usual reason a wired provider "doesn't show up".
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
- **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
|
|
33
|
-
- **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["offline", "card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off. Omitting it means offline-only, cards off — so **enabling the card gateway is part of wiring a provider**, not a separate concern: a correct `card-payment.ts` is invisible at checkout until that call runs, and that call without the file gives customers a `503`.
|
|
34
|
-
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. It should also check the payment **names this order** (compare the payment's metadata `order_id` to `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
35
|
-
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` + `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
36
|
-
- **`parseWebhook` never trusts a raw body.** Return `paid: false` (the verify-via-API nudge) unless you verified the provider's signature over the raw payload bytes; set `reference` only from a verified event, otherwise leave it unset so the premade flow uses the reference stored on the order at checkout.
|
|
37
|
-
- **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.
|
|
38
|
-
- The shared helpers in `base44/shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
40
|
+
That is the whole of it. The file expects the app to be connected to Stripe and reads the secret key that connection publishes; **connecting the app is outside this kit and not described here.** Nothing in the file needs filling in, and no key belongs in the code. A missing key is not silent — checkout answers `503 no_card_payment_provider`.
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
A kit update re-copies `shared/commerce/` and restores the stub — re-run the copy after updating. A different provider ships the same way (`card-payment.<provider>.ts` beside the stub); until one does, implement the four functions against its API per the rules below, with the Stripe file as the worked model.
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
## How the premade flow works
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
1. **Checkout** (`place-order` with the `card` gateway): the order is created `pending`, `createCardPayment` runs, 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`.
|
|
47
|
+
2. **Confirmation — two idempotent paths**, whichever runs second is a no-op: the **customer return** (`/order-received` calls `commerce/payments` `complete-return`, which runs `checkCardPaymentPaid` and on true moves the order to `processing`, firing the stock/email/webhook side-effects — `useOrderReturn` is that page in one hook), and the **webhook**, which covers the buyer who pays and closes the tab.
|
|
48
|
+
3. **Payment links**: `commerce/payments` `create-link` mints a fresh page for any unpaid order through the same `createCardPayment` — the admin's payment-link button and order-received's "Pay now".
|
|
49
|
+
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 dashboard.
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
With the gateway enabled and no provider behind it, picking Credit card answers `503 no_card_payment_provider` — implement the file, or switch the option off in Settings → Payments. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions and the operator moves it on when the money arrives. Those need no code, and the admin can add more of them.
|
|
49
52
|
|
|
50
|
-
##
|
|
51
|
-
|
|
52
|
-
**A worked example, not a bundled provider**: the template ships with no provider at all, and Stripe is one option among many (PayPal, Adyen, a local PSP — the four functions are the same shape against any API). Paste this in when Stripe is the provider the store actually chose; read it as a model of the four contracts when the provider is something else. Either way, only once §4.1's rule says cards are wanted at all.
|
|
53
|
-
|
|
54
|
-
Overwrite `base44/shared/commerce/card-payment.ts` with this **as one whole-file write** (never `find_replace` into the stubs — see *Implementation rules* above):
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
// base44/shared/commerce/card-payment.ts — Stripe implementation
|
|
58
|
-
import Stripe from "npm:stripe@18";
|
|
59
|
-
import { HttpError } from "./auth.ts";
|
|
60
|
-
|
|
61
|
-
export interface CardPaymentPage {
|
|
62
|
-
url: string; // where the customer goes to pay
|
|
63
|
-
reference: string; // the provider's id for this payment, stored on the order
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const stripe = () => {
|
|
67
|
-
const key = Deno.env.get("STRIPE_SECRET_KEY");
|
|
68
|
-
if (!key) {
|
|
69
|
-
throw new HttpError(503, "Card payments are not configured — the STRIPE_SECRET_KEY secret is missing.", "no_card_payment_provider");
|
|
70
|
-
}
|
|
71
|
-
return new Stripe(key);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// Stripe amounts are in minor units; these currencies have none.
|
|
75
|
-
const ZERO_DECIMAL = new Set(["BIF","CLP","DJF","GNF","JPY","KMF","KRW","MGA","PYG","RWF","UGX","VND","VUV","XAF","XOF","XPF"]);
|
|
76
|
-
const minorUnits = (amount: number, currency: string) =>
|
|
77
|
-
Math.round(Number(amount) * (ZERO_DECIMAL.has(String(currency).toUpperCase()) ? 1 : 100));
|
|
78
|
-
|
|
79
|
-
export async function createCardPayment(
|
|
80
|
-
_sr: any,
|
|
81
|
-
order: any,
|
|
82
|
-
opts: { successUrl: string; cancelUrl: string; customerEmail?: string },
|
|
83
|
-
): Promise<CardPaymentPage> {
|
|
84
|
-
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
85
|
-
// names the order when Stripe's event arrives — keep it on both objects.
|
|
86
|
-
const metadata = { order_id: String(order.id), order_key: String(order.order_key) };
|
|
87
|
-
const session = await stripe().checkout.sessions.create({
|
|
88
|
-
mode: "payment",
|
|
89
|
-
line_items: [{
|
|
90
|
-
quantity: 1,
|
|
91
|
-
price_data: {
|
|
92
|
-
currency: String(order.currency || "USD").toLowerCase(),
|
|
93
|
-
product_data: { name: `Order #${order.order_number}` },
|
|
94
|
-
unit_amount: minorUnits(order.total, order.currency),
|
|
95
|
-
},
|
|
96
|
-
}],
|
|
97
|
-
customer_email: opts.customerEmail || undefined,
|
|
98
|
-
metadata,
|
|
99
|
-
payment_intent_data: { metadata },
|
|
100
|
-
success_url: opts.successUrl,
|
|
101
|
-
cancel_url: opts.cancelUrl,
|
|
102
|
-
});
|
|
103
|
-
if (!session.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
104
|
-
return { url: session.url, reference: session.id };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
108
|
-
const session = await stripe().checkout.sessions.retrieve(reference);
|
|
109
|
-
// The payment must be for THIS order — stops a reference to some other
|
|
110
|
-
// (paid) session being replayed against a different order.
|
|
111
|
-
return session.payment_status === "paid" && session.metadata?.order_id === String(order.id);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
115
|
-
reference: string; amount: number; currency: string; reason?: string;
|
|
116
|
-
}): Promise<{ refund_id: string }> {
|
|
117
|
-
const session = await stripe().checkout.sessions.retrieve(opts.reference);
|
|
118
|
-
if (!session.payment_intent) {
|
|
119
|
-
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
120
|
-
}
|
|
121
|
-
const refund = await stripe().refunds.create({
|
|
122
|
-
payment_intent: String(session.payment_intent),
|
|
123
|
-
amount: minorUnits(opts.amount, opts.currency),
|
|
124
|
-
});
|
|
125
|
-
return { refund_id: refund.id };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
129
|
-
export interface CardWebhookEvent {
|
|
130
|
-
order_id: string;
|
|
131
|
-
order_key: string;
|
|
132
|
-
paid: boolean; // true only after signature verification — never from a raw body
|
|
133
|
-
reference?: string; // only if signature-verified; otherwise the order's stored reference is used
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Stripe webhook events. The event body is never trusted: this only names
|
|
138
|
-
* the order (from the metadata createCardPayment attached) and returns
|
|
139
|
-
* paid: false, so the premade webhook verifies against Stripe's API through
|
|
140
|
-
* checkCardPaymentPaid — a forged call can never mark an order paid, and no
|
|
141
|
-
* signing secret is needed. (Optional fast path that skips the API
|
|
142
|
-
* round-trip: verify the signature instead — references/online-payments.md.)
|
|
143
|
-
*/
|
|
144
|
-
export async function parseWebhook(_req: Request, payload: string): Promise<CardWebhookEvent | null> {
|
|
145
|
-
let event: any;
|
|
146
|
-
try { event = JSON.parse(payload); } catch { return null; }
|
|
147
|
-
const metadata = event?.data?.object?.metadata;
|
|
148
|
-
if (!metadata?.order_id || !metadata?.order_key) return null;
|
|
149
|
-
return { order_id: String(metadata.order_id), order_key: String(metadata.order_key), paid: false };
|
|
150
|
-
}
|
|
151
|
-
```
|
|
53
|
+
## Implementation rules
|
|
152
54
|
|
|
153
|
-
|
|
55
|
+
For a **custom** provider (the shipped files already obey all of these):
|
|
154
56
|
|
|
155
|
-
|
|
156
|
-
|
|
57
|
+
- **Write `card-payment.ts` whole** — one write of the complete file, never a `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 …"). The fix is always the whole-file write — which is also why the shipped provider files exist.
|
|
58
|
+
- **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no credential yet answers a clean 503 instead of failing to boot every function that imports it.
|
|
59
|
+
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference` **and** check the payment names this order (its metadata `order_id` vs `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
60
|
+
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` **and** `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
61
|
+
- **Amounts**: `order.total` is in display units (`12.34`) with `order.currency`; convert to the provider's minor units yourself, remembering the zero-decimal currencies.
|
|
62
|
+
- The helpers in `shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
157
63
|
|
|
158
|
-
|
|
159
|
-
await fetch("https://api.stripe.com/v1/webhook_endpoints", {
|
|
160
|
-
method: "POST",
|
|
161
|
-
headers: { Authorization: `Bearer ${Deno.env.get("STRIPE_SECRET_KEY")}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
162
|
-
body: new URLSearchParams({ url: "https://<app-domain>/functions/commerce/payment-webhook", "enabled_events[]": "checkout.session.completed" }),
|
|
163
|
-
});
|
|
164
|
-
```
|
|
64
|
+
## `parseWebhook` in depth
|
|
165
65
|
|
|
166
|
-
|
|
66
|
+
`parseWebhook(req, payload)` lives in `card-payment.ts` with the other three; `commerce/payment-webhook` is premade and calls it with the raw request and the raw body — the exact bytes, so signature schemes work. It returns `CardWebhookEvent | null`:
|
|
167
67
|
|
|
168
|
-
|
|
68
|
+
- **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 through `checkCardPaymentPaid` against the provider's API, so forgery is impossible by construction — a forged call can at worst trigger a re-check, and the `order_key` match stops an event being aimed at another order. This is what the shipped Stripe file does.
|
|
69
|
+
- **The signature-verified fast path** (optional): verify the signature over the raw `payload` bytes (Stripe: `constructEventAsync` with a webhook signing secret) and return `paid: true` with the event's `reference`; 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.** Set `reference` only from a verified event; otherwise leave it unset and the flow uses the reference stored on the order at checkout.
|
|
70
|
+
- Return **`null`** for events that aren't about one of this store's payments; the function answers 200 so the provider doesn't retry.
|
|
169
71
|
|
|
170
|
-
|
|
171
|
-
- **Build the `/order-received` page** (or set your route in Settings → General → *Payment return path*): call `commerce/payments` `complete-return` with the return query params and render its `state` (`paid | cancelled | unpaid`, with `payment_link` for "Pay now" while unpaid). Without this page a paying customer lands on a 404 and the order is never marked paid.
|
|
172
|
-
- Handle `503 no_card_payment_provider` from `place-order` by telling the customer card payment is unavailable and offering the other methods.
|
|
72
|
+
Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
stage: reference
|
|
3
|
+
read_when: "You are setting up recurring maintenance, sizing the store against SDK/entity limits, or wiring outbound webhooks to another system."
|
|
4
|
+
skip_when: "You are building the store itself — every maintenance job also runs opportunistically, so nothing here blocks a launch."
|
|
5
|
+
forget_when: "The workflows you decided to create exist, or you have confirmed the store's volume is inside the limits below."
|
|
6
|
+
carry_forward:
|
|
7
|
+
- "No workflow/schedule files ship — recurring jobs are workflows you create in the app, running with admin privileges."
|
|
8
|
+
- "There is no total-count API and no transactions: lists are limit+1 probes, counters can drift, and admin-tools recount actions repair them."
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Operations: schedules, limits, webhooks
|
|
12
|
+
|
|
13
|
+
## Scheduled work (workflows)
|
|
14
|
+
|
|
15
|
+
**The kit ships no workflow or schedule files, on purpose** — don't look for them under `base44/` and don't add them there. Base44 *has* a scheduler: you (or the Base44 agent) create the workflows **in the app**, one per row below, each invoking one function action on a cadence:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
await base44.functions.invoke("commerce/admin-tools", { action: "clear-abandoned-carts", older_than_days: 2 });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Workflow to create | What it does | Action to invoke | Payload | Cadence |
|
|
22
|
+
|---|---|---|---|---|
|
|
23
|
+
| Release expired stock holds | Frees stock held by unpaid orders past `hold_stock_minutes`. `storefront-checkout place-order` already runs this at the start of every checkout, so the schedule mainly covers quiet periods. | `commerce/admin-orders` `release-expired-holds` | — | every 5–15 min |
|
|
24
|
+
| Abandoned cart cleanup | Marks carts past the 48 h TTL as `abandoned`. Also happens lazily on next cart access. | `commerce/admin-tools` `clear-abandoned-carts` | `{ older_than_days: 2 }` | hourly–daily |
|
|
25
|
+
| Prune webhook delivery log | Deletes old `commerce.WebhookDelivery` rows so the log doesn't grow unbounded. **No opportunistic fallback — this one genuinely needs a schedule.** | `commerce/admin-tools` `prune-webhook-deliveries` | `{ keep_days: 30 }` | daily–weekly |
|
|
26
|
+
|
|
27
|
+
**Optional — drift repair.** For self-healing counters (see *Limits*), add a nightly/weekly workflow calling the `commerce/admin-tools` recount actions: `recount-terms`, `recount-coupon-usage`, `recalculate-customer-stats-all`, `regenerate-download-permissions`.
|
|
28
|
+
|
|
29
|
+
Every action above is guarded by `requireAdmin()`, so each workflow must run with **admin privileges** (an admin identity / service context), never as an anonymous caller.
|
|
30
|
+
|
|
31
|
+
## Limits, concurrency & reports
|
|
32
|
+
|
|
33
|
+
- **Pagination.** SDK `filter`/`list` cap at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`shared/commerce/scan.ts` `scanAll`, page size 500); admin lists and `useProductList` use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
34
|
+
- **Search** is server-side (`search` actions scan + JS-filter) because entity `filter` is exact-match only.
|
|
35
|
+
- **Reports** scan on demand — fine to ~10k orders per range. `commerce/admin-reports` `summary`/`sales`/`top-sellers` scan `commerce.Order` (+ `commerce.OrderRefund`) filtered to counted orders (`date_paid` set, or status `processing`/`completed`); net sales = gross − refunds − tax − shipping. Beyond that, cache `summary` and materialize an `OrderStats` entity (one record per day per status, summed totals) updated on each order transition.
|
|
36
|
+
- **No transactions.** Four consequences, all documented in code:
|
|
37
|
+
- `nextOrderNumber` is `max(order_number)+1` with a small retry; two concurrent checkouts could theoretically collide — acceptable for typical volume, or front it with a counter entity.
|
|
38
|
+
- Stock decrement is last-write-wins; oversell is possible on simultaneous checkouts of the last unit. The hold mechanism (`hold_stock_minutes`) mitigates it; a stricter reserve step is yours to add.
|
|
39
|
+
- Denormalized counters (`usage_count`, `total_sales`, `orders_count`, term `count`) can drift; the recount actions above repair them.
|
|
40
|
+
- **Record size.** Orders embed their line/shipping/tax/fee/coupon lines, so orders with hundreds of distinct line items push against per-record size limits.
|
|
41
|
+
|
|
42
|
+
## Webhooks (outbound)
|
|
43
|
+
|
|
44
|
+
Managed in the admin at **Settings → Webhooks** (`settings/webhooks`; the source still lives under `pages/status/`).
|
|
45
|
+
|
|
46
|
+
`shared/commerce/webhooks.ts` `dispatch()` fires on `order.*`, `product.*`, `customer.*`, `coupon.*` (created/updated/deleted — `restored` is selectable on a webhook but **never emitted**). Each active `commerce.Webhook` matching the topic gets an HTTP POST with headers `X-Commerce-Webhook-Topic/-Resource/-Event/-ID/-Delivery-ID/-Signature`; the signature is **base64 HMAC-SHA256** of the body keyed by the webhook's `secret` (Web Crypto) — verify by recomputing it over the **raw** body.
|
|
47
|
+
|
|
48
|
+
- Every attempt is logged as a `commerce.WebhookDelivery` (request/response bodies truncated to 32 KB). Prune it on a schedule (above).
|
|
49
|
+
- `failure_count` increments on non-2xx/timeout and resets on success; a webhook **auto-disables after 5 consecutive failures**.
|
|
50
|
+
- The `secret` lives on the (admin-only-RLS) `commerce.Webhook` entity. For higher assurance, move it to Base44 secrets and read it in `dispatch()`.
|
|
51
|
+
|
|
52
|
+
Inbound payment webhooks are a different mechanism entirely — [`online-payments.md`](./online-payments.md).
|