@base44/app-plugin-commerce 0.1.0
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/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Post-installation
|
|
2
|
+
|
|
3
|
+
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, decide with the user how the store's data gets created, and register the template + skill in `AGENTS.md`. Installed into the app at `skills/commerce/post-installation.md`.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Embedding the admin pages
|
|
8
|
+
|
|
9
|
+
The admin UI is a self-contained React app under `src/commerce/admin/`. Its only external touchpoints are `@/components/ui/*` (shadcn) and `@/api/base44Client` (your app's SDK client). `src/commerce/utils/` sits alongside it — framework-free storefront helpers (variant selection, free-shipping rules) with no dependencies, used by the customer-facing UI you build, not by the admin.
|
|
10
|
+
|
|
11
|
+
**Steps:**
|
|
12
|
+
|
|
13
|
+
1. Copy `src/commerce/admin/` → `src/commerce/admin/` and `src/commerce/utils/` → `src/commerce/utils/` (already done if you ran `scripts/install.js`).
|
|
14
|
+
2. Verify `sonner` and `recharts` are installed and install them if not (`npm i sonner recharts`) — both ship with the default Base44 template, as does `react-markdown`, which the StoreAdmin bot uses (add it if your app lacks it). The template needs **no other dependency**. Verify the shadcn primitives listed in `src/commerce/admin/README.md` are present (`npx shadcn@latest add <name>` for any missing).
|
|
15
|
+
3. Mount the router:
|
|
16
|
+
```jsx
|
|
17
|
+
import AdminApp from "@/commerce/admin";
|
|
18
|
+
<Route path="/admin/*" element={<AdminApp />} />
|
|
19
|
+
```
|
|
20
|
+
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is a thin wrapper over one backend call, `commerce/payments` `complete-return`, which confirms the payment and returns `{ state, order, payment_link }` for the paid / unpaid / cancelled cases — the contract and rules are in [`references/online-payments.md`](./references/online-payments.md).
|
|
21
|
+
|
|
22
|
+
**Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/admin" replace />} />`.
|
|
23
|
+
|
|
24
|
+
The **`/*` splat is required** — the admin renders its own nested routes, so a bare `path="/admin"` matches only the dashboard and every deeper link 404s. If you mount at a different base path, pass the prefix *without* the splat: `<AdminApp basePath="/store-admin" />` (a pattern passed by mistake, `basePath="/store-admin/*"`, is normalized rather than baked into every link). Opening the literal mount pattern `/admin/*` — pasted from these docs, or left in a hand-written nav link — lands on the dashboard instead of a "page not found" dead end; a genuinely wrong path like `/admin/ordrs` still 404s, with a link back.
|
|
25
|
+
|
|
26
|
+
### Admin-role enforcement (do not weaken)
|
|
27
|
+
|
|
28
|
+
The shipped `AuthGuard` requires an authenticated user **whose `role === "admin"`**:
|
|
29
|
+
|
|
30
|
+
- Not logged in → "Please sign in" screen.
|
|
31
|
+
- Logged in but **not** admin → "Admin access required" screen (a merely-authenticated customer cannot reach any admin page).
|
|
32
|
+
|
|
33
|
+
Grant the role via the Base44 dashboard (user management) or `base44.users.inviteUser(email, "admin")`.
|
|
34
|
+
|
|
35
|
+
**Do not relax this check.** It is the first of three enforcement layers:
|
|
36
|
+
|
|
37
|
+
1. **UI guard** — `AuthGuard` (client-side; convenience + UX).
|
|
38
|
+
2. **Entity RLS** — every admin-only entity (commerce.Order, commerce.Customer, commerce.Coupon, commerce.StoreSettings, …) has `"user_condition": { "role": "admin" }` on all operations, so direct SDK reads/writes from a non-admin are rejected by the backend.
|
|
39
|
+
3. **Function guard** — every `commerce/admin-*` function (and `commerce/seed-store`) calls `requireAdmin()`, returning **401** if unauthenticated and **403** if not an admin, before touching data via the service role.
|
|
40
|
+
|
|
41
|
+
Even if the client guard were bypassed, layers 2 and 3 keep the store data safe. Storefront functions are intentionally public and verify the caller per-action instead (auth session, `cart_token`, or `order_key`).
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 2. Store data — ask the user before seeding anything
|
|
46
|
+
|
|
47
|
+
A fresh install has **no settings and no catalog**. Don't decide this silently and don't default to the demo catalog: **ask the user which of these three they want**, then do exactly that.
|
|
48
|
+
|
|
49
|
+
> Suggested wording: *"Before you open the admin — should I generate a starter catalog for your store (products, descriptions, prices, images, categories, variants) based on what you're selling, seed the template's generic demo catalog so you can click around, or leave the store empty and set it up yourself? Generating one costs tokens — I write every product's copy and generate an image each — while the demo catalog is static and effectively free."*
|
|
50
|
+
|
|
51
|
+
| Option | What you do | Result | Cost |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| **A — Generate the store's data** (best when you know the app's niche) | `commerce/seed-store` with `{ store_name, with_sample_data: false }`, then create real categories, attributes, products and images through the admin API (§2.1) | Store is **ready** — the admin opens straight onto a populated dashboard, no first-run screen | **Spends tokens**: you author every name, description and price, and image generation is billed per image |
|
|
54
|
+
| **B — Default demo data** | `commerce/seed-store` with `{ store_name, with_sample_data: true }` | Store is **ready**, populated with the template's ~12 generic demo products | **Near-zero**: one function call, static content and stock image URLs |
|
|
55
|
+
| **C — Nothing** | don't call `seed-store` at all | The admin shows its first-run **"Set up your store"** screen; the operator initializes defaults themselves | none |
|
|
56
|
+
|
|
57
|
+
Say the cost part out loud when you ask — it's the main trade-off between A and B, and the user is the one paying for it. A generated catalog of a dozen products with images is a real chunk of generation (text for every product plus one image call each); the static demo seed is a single backend call with no generation at all. If the user wants a populated store only to click around the admin, B is the better deal; A is worth it when the catalog is meant to survive into the real store.
|
|
58
|
+
|
|
59
|
+
**Both A and B must pass `store_name`** — the app's name as the platform shows it. It is required on a first seed (**400** `store_name_required` without it), because nothing server-side can read the app's name and an unnamed store sends subjects like `[]: New order #1002`. On an already-seeded store the call fills a blank name and never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action }` (`created` · `filled` · `unchanged` · `kept_existing`). The sender name is separate and needs nothing: leave `emails.from_name` blank and Base44 sends as the app's name.
|
|
60
|
+
|
|
61
|
+
Both A and B call `seed-store`, which creates the business defaults — the seven settings groups (`general`, `products`, `inventory`, `downloadable`, `tax`, `shipping`, `emails`), payment gateways, tax classes and a fallback shipping zone. **Creating the `general` settings group is what marks the business as ready:** the admin's `SettingsProvider` (`src/commerce/admin/context/SettingsContext.jsx`) renders the first-run setup screen only while that group is missing. So under A and B the modal never appears — do **not** add a separate "ready" flag or suppress the screen in code; leave it working for option C, which is the only case it's meant for.
|
|
62
|
+
|
|
63
|
+
Confirm afterwards:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
|
|
67
|
+
// → seeded: true (i.e. settings_groups includes "general"),
|
|
68
|
+
// settings_groups: ["general", "products", ...], counts: { Product: n, ... }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 2.1 Generating the store's data (option A)
|
|
72
|
+
|
|
73
|
+
The goal is a catalog that looks like **this** business, not a demo. Order of operations:
|
|
74
|
+
|
|
75
|
+
1. **Defaults first** — `commerce/seed-store` with `{ with_sample_data: false, store_name: "<the app's name>" }`. `store_name` is **required** on a first seed (else **400** `store_name_required`) — use the app's name as the platform shows it (ask the user if unsure — `base44/config.jsonc` → `name` can be stale, e.g. `New App` for an app called `Canvas`), since the function cannot read it. It lands as the store name and, by inheritance, the "from" name on transactional emails. It's idempotent and runs a canary schema check; on `422 schema_incompatible` fix the reported entities before continuing. Never follow it with `with_sample_data: true` — you'd mix demo products into a real catalog (the sample seeder is skipped once any product exists, so this usually silently does nothing, which is worse).
|
|
76
|
+
2. **Business settings** — ask for, or infer from the app, the store name, currency, base country/state and units, and write them into the relevant `commerce.StoreSettings` groups (direct CRUD, one record per `group_id`; patch `values`, don't replace groups you weren't asked about).
|
|
77
|
+
3. **Taxonomy** — create `commerce.ProductCategory` (direct CRUD, nest via `parent_id`) and, if products vary, `commerce.ProductAttribute` + `commerce.ProductAttributeTerm` (e.g. Size, Color). Reuse one global attribute across products rather than duplicating per-product local ones.
|
|
78
|
+
4. **Products** — `commerce/admin-products` `save` (or `batch`, ≤100 items) per product. Write real merchandising copy: a distinct `name`, a one-line `short_description`, an HTML `description` (paragraph + `<ul>` of specifics), plus `sku`, `regular_price`, optional `sale_price`, `manage_stock: true` + `stock_quantity` where stock is tracked, `category_ids` and `images[]`. (`manage_stock` is a boolean on Product but `"yes"|"no"|"parent"` on ProductVariation.)
|
|
79
|
+
5. **Variable products — an attribute in the attributes list does NOTHING on its own.** This is the step agents get wrong: they create a global `Size` attribute with terms, and every product stays simple, unsellable as a variant. A product is only variable when **all four** of these are true, and the last two happen in the *same* `commerce/admin-products` `save` call:
|
|
80
|
+
1. `type: "variable"` on the product;
|
|
81
|
+
2. the attribute is listed **on the product** with `variation: true` and its `options` — not just in `commerce.ProductAttribute`;
|
|
82
|
+
3. a `variations` array is sent with one entry per combination you actually stock;
|
|
83
|
+
4. `default_attributes` names the combination to pre-select.
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
await call("admin-products", "save", {
|
|
87
|
+
product: {
|
|
88
|
+
name: "Runner Sneaker", type: "variable", status: "publish", sku: "SNK",
|
|
89
|
+
regular_price: 89, // lowest variation price, so cards/filters work
|
|
90
|
+
category_ids: [shoesId], images: [{ src: "…" }],
|
|
91
|
+
attributes: [{ attribute_id: sizeAttrId, name: "Size", position: 0,
|
|
92
|
+
visible: true, variation: true, options: ["41", "42", "43"] }],
|
|
93
|
+
default_attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "42" }],
|
|
94
|
+
},
|
|
95
|
+
variations: [
|
|
96
|
+
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "41" }],
|
|
97
|
+
sku: "SNK-41", regular_price: 89, manage_stock: "yes", stock_quantity: 4, status: "publish" },
|
|
98
|
+
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "42" }],
|
|
99
|
+
sku: "SNK-42", regular_price: 89, manage_stock: "yes", stock_quantity: 6, status: "publish" },
|
|
100
|
+
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "43" }],
|
|
101
|
+
sku: "SNK-43", regular_price: 89, manage_stock: "yes", stock_quantity: 2, status: "publish" },
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Give a visual axis (Color) a per-variation `image`. `manage_stock` is `"yes"|"no"|"parent"` on a variation but a boolean on the product. **Then check your work**: re-read the product and assert `variations.length > 0` — a variable product with no variations cannot be added to a cart at all (`400 variation_required`), and the storefront has nothing to show.
|
|
107
|
+
6. **Images** — every product needs at least one. 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 the `{ src, name, alt }` shape and a working Unsplash pattern). Match the image to the product — a generated catalog with mismatched or missing images reads as broken.
|
|
108
|
+
7. **Optional extras** — a launch coupon via `commerce/admin-coupons` `save`; tax rates via `commerce.TaxRate` direct CRUD if the user sells into taxed regions.
|
|
109
|
+
8. **Payments — once the store has something to sell.** Leave this until the catalog, shipping and settings are in place: connecting a payment provider is a step the *user* has to take in the platform dashboard, so asking for it first interrupts setup and leaves nothing to test the payment against. When the store is otherwise ready, raise it — don't wait to be asked.** Card payments are **already implemented** (hosted payment page, payment links, refunds); they only need a provider connected. The other gateways (`cod`, `bacs`, `cheque`) are **manual** — nobody pays online and someone reconciles by hand. So tell the user card payments are ready and ask them to connect the **Stripe** connector for the app, then **redeploy the backend functions** — the platform injects `STRIPE_SECRET_KEY` at deploy time, so a provider connected after the last deploy is invisible to already-deployed functions and the store still reports *no payment provider connected*. On a hosted app, edit any file under `base44/shared/commerce/` (bump the deploy marker at the top of `payments.ts`) to redeploy every `commerce/*` function; on the CLI, `npx base44 functions deploy`. Then confirm with `commerce/admin-tools` → `payment-connector-status`. Nothing to code. The card gateway is enabled by default and stays hidden from customers until a provider is connected, so enabling it early is safe. Declining is a legitimate choice — then switch that gateway off and make the manual flow explicit in the checkout copy. Details: [`references/online-payments.md`](./references/online-payments.md).
|
|
110
|
+
9. **Report back** — tell the user what you created (counts by category/type, currency, enabled gateways) and that the store is ready at `/admin`.
|
|
111
|
+
|
|
112
|
+
Keep the generated catalog small unless asked — roughly 8–15 products across 3–5 categories, with at least one variable product if the business plausibly has options. Cost scales with the catalog: every product is generated copy plus at least one generated image, so confirm before going past that range rather than quietly producing a 50-product store. Products are created as `status: "publish"` only if you set it; leave them `draft` if the user wants to review before going live.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 3. Register the template and skill in `AGENTS.md`
|
|
117
|
+
|
|
118
|
+
So future agent sessions know the store exists and read the skill before touching it, **edit the app's existing `AGENTS.md` in place** — add the template to an **Installed templates** section and point at the skill from a **My Skills** section. Do **not** replace or overwrite the file: keep everything already in it and only append these two entries, creating either section only if it doesn't already exist (if a section is already there, add to it rather than duplicating it). The snippet below shows the entries to merge in, not a full-file replacement:
|
|
119
|
+
|
|
120
|
+
```md
|
|
121
|
+
## Installed templates
|
|
122
|
+
|
|
123
|
+
- commerce
|
|
124
|
+
|
|
125
|
+
## My Skills
|
|
126
|
+
|
|
127
|
+
- `skills/commerce/SKILL.md` — Base44 Commerce template: 24 `commerce.*` entities, 16 `commerce/*` backend functions (storefront + admin APIs + online payments), the shared commerce engine under `base44/shared/commerce/`, the store admin UI mounted at `/admin`, and the `commerce/StoreAdmin` agent (admin copilot bot in the admin sidebar). Read before working on store features — catalog, cart, checkout, orders, payments, emails, webhooks, or the admin UI.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 4. Reviews — build them unless the user rules it out
|
|
133
|
+
|
|
134
|
+
**Unless you are sure the store doesn't want reviews, implement them: the backend already exists.** Skipping them is the common miss, and it costs the store the social proof most shoppers look for before buying.
|
|
135
|
+
|
|
136
|
+
What ships, with nothing to write on the backend:
|
|
137
|
+
|
|
138
|
+
- `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
|
|
139
|
+
- `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
|
|
140
|
+
- `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a **signed-in** customer — the email comes from the session, never the payload;
|
|
141
|
+
- `commerce/storefront-account` `my-reviews` lists a customer's own;
|
|
142
|
+
- moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
|
|
143
|
+
|
|
144
|
+
So the work is UI: stars on cards and the product page, a reviews list, and a submit form. Behaviors to respect rather than fight:
|
|
145
|
+
|
|
146
|
+
- anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
|
|
147
|
+
- a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
|
|
148
|
+
- `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
|
|
149
|
+
- the store can switch reviews off (`products.enable_reviews`) or per product (`reviews_allowed`) — read those and hide the UI when false.
|
|
150
|
+
|
|
151
|
+
Shapes and error codes: [`docs/api-storefront.md`](./docs/api-storefront.md#submit-review). Where ratings belong per view: [`references/product-render.md`](./references/product-render.md).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 5. Next
|
|
156
|
+
|
|
157
|
+
Continue with the commerce skill — [`skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, storefront building (start with [`references/product-render.md`](./references/product-render.md) for what to render, then [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variants), Stripe wiring, scheduled maintenance, emails, webhooks, and operational limits.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Emails
|
|
2
|
+
|
|
3
|
+
Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`. Ten order emails are wired to the lifecycle (see the side-effect matrix in [`skills/commerce/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 template.
|
|
4
|
+
|
|
5
|
+
- Per-type 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.
|
|
6
|
+
- 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. 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 in `commerce.EmailLog` 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`).
|
|
7
|
+
- Stock notifications follow the same chain: `inventory.notification_recipient`, else the first `emails.admin_recipients` entry, else the first admin user.
|
|
8
|
+
- The **"from" name** is `emails.from_name`, and it seeds **blank on purpose**: it inherits `general.store_name`, and when both are blank `SendEmail` is called without `from_name`, in which case **Base44 sends as the app's name** (verified: an app named *Canvas* with both 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. Settings → Emails shows the inherited value as the field's placeholder.
|
|
9
|
+
- That fallback does **not** reach subjects or headings: those are rendered by this template, which has no access to the app name, so a store with no `general.store_name` sends `New order #1002` rather than `[Canvas]: New order #1002`. Set the store name if you want it in the subject line.
|
|
10
|
+
- 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`, never `[]: New order #1002`.
|
|
11
|
+
- `emails_sent[]` on each order dedupes lifecycle emails so a re-entered status won't re-send.
|
|
12
|
+
- Deliverability (SPF/DKIM) and the sending address depend on your Base44 email configuration — the store only sets `from_name`. Every send is recorded in the `commerce.EmailLog` entity.
|
|
13
|
+
- **Reading `commerce.EmailLog`:** one row per address per attempt. `target` is `admin` or `customer` — the same split the Recipient column shows in Settings → Emails (stock notifications count as `admin`). `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. Rows written before these fields existed still carry the record id in `order_id` and have no `target`; they are not backfilled.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Guest access & security
|
|
2
|
+
|
|
3
|
+
- 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 has to enforce that in the shopfront you build (and, if it must not be bypassable, by adding the check to `commerce/storefront-checkout` `place-order`).
|
|
4
|
+
- **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`, `purchase_note`, draft/hidden products, stock and sales internals, gateway `settings` such as BACS 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
|
+
- The storefront catalog function strips the paywalled fields (`downloads`, `download_limit`, `download_expiry`, `purchase_note`) 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
|
+
- `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
|
+
- 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 (UI guard, entity RLS, `requireAdmin()` function guard) — see [`post-installation.md`](../post-installation.md); never weaken them.
|
|
9
|
+
|
|
10
|
+
## Rules for anything you add
|
|
11
|
+
|
|
12
|
+
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
|
+
|
|
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
|
+
- **Anything a person owns needs authentication, not just an email.** If you add reviews, 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.
|
|
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 `confirmOnlinePayment()`, which asks the provider and checks the session was opened for that order. Adding a gateway means writing an adapter (see [`online-payments.md`](./online-payments.md)), not a new "confirm" endpoint.
|
|
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
|
+
- **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.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Limits, concurrency & reports performance
|
|
2
|
+
|
|
3
|
+
## Limits & concurrency
|
|
4
|
+
|
|
5
|
+
- **Pagination.** The SDK `filter`/`list` cap out at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`shared/scan.ts` `scanAll`, page size 500). Admin lists use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
6
|
+
- **Search** is server-side (`search` actions scan + JS-filter) because entity `filter` is exact-match only.
|
|
7
|
+
- **Reports** scan orders on demand — fine to ~10k orders per range. Beyond that, materialize an `OrderStats` entity updated on each order transition and aggregate from it (sketch: one record per day per status with summed totals; `commerce/admin-reports` reads the pre-aggregated rows instead of scanning `commerce.Order`).
|
|
8
|
+
- **No transactions.** A few consequences, all documented in code:
|
|
9
|
+
- `nextOrderNumber` is `max(order_number)+1` with a small retry; under heavy concurrent checkout two orders could theoretically collide — acceptable for typical volume, or front it with a dedicated counter entity if needed.
|
|
10
|
+
- Stock decrement is last-write-wins; oversell is possible under simultaneous checkouts of the last unit. Mitigate with the hold mechanism (already in place) or a stricter reserve step if your volume warrants.
|
|
11
|
+
- Denormalized counters (`usage_count`, `total_sales`, `orders_count`, term `count`) can drift; `commerce/admin-tools` recount actions repair them (schedule them via [`scheduled-work.md`](./scheduled-work.md)).
|
|
12
|
+
- **Record size.** Orders embed their line/shipping/tax/fee/coupon lines. Extremely large orders (hundreds of distinct line items) push against per-record size limits; split or paginate if you expect that.
|
|
13
|
+
|
|
14
|
+
## Reports performance
|
|
15
|
+
|
|
16
|
+
The `commerce/admin-reports` `summary`/`sales`/`top-sellers` actions scan `commerce.Order` (and `commerce.OrderRefund`) filtered to counted orders (`date_paid` set, or status `processing`/`completed`). Net sales = gross − refunds − tax − shipping (standard convention). For large catalogs, cache `summary` on the dashboard and consider the `OrderStats` materialization above.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Images & downloadable files
|
|
2
|
+
|
|
3
|
+
- **Catalog images** — the admin `MediaUploader` uses `base44.integrations.Core.UploadFile({ file })` → public URL, stored in `product.images[]` / `variation.image`.
|
|
4
|
+
- **Downloadable products** — store files as the download's `file_url`. For private files, upload with `Core.UploadPrivateFile` (stores a `file_uri`, not an `http` URL). `commerce/storefront-account` `get-download` detects non-`http` URIs and returns a short-lived signed URL via `Core.CreateFileSignedUrl` (1-hour expiry), decrementing `downloads_remaining` and enforcing `access_expires`.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Online payments
|
|
2
|
+
|
|
3
|
+
**Card payments are implemented and shipped.** Hosted payment page, payment links for existing orders, two confirmation paths, and refunds through the provider — all wired to **Stripe** out of the box. There is **no charge flow to write**: the store starts taking card payments as soon as a payment provider is connected.
|
|
4
|
+
|
|
5
|
+
- `base44/shared/commerce/payments.ts` — the **provider-neutral payment utility** every caller uses, plus the one-line `ACTIVE_PROVIDER` binding.
|
|
6
|
+
- `base44/shared/commerce/stripe.ts` — the Stripe adapter (the only file that speaks Stripe).
|
|
7
|
+
- `base44/functions/commerce/payments/` — `status` · `create-link` · `verify`.
|
|
8
|
+
- `base44/functions/commerce/payment-webhook/` — the provider's signed callback.
|
|
9
|
+
- `commerce/storefront-checkout` `place-order` returns a `checkout_url`; `commerce/admin-refunds` refunds through the provider; the admin order page has a **Payment** panel.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. What to do when building a store (strong recommendation, not a rule)
|
|
14
|
+
|
|
15
|
+
A store that can't take a card is usually not what the user wants: the gateways that work with no provider at all (`cod`, `bacs`, `cheque`) are **manual** — the customer leaves without paying and someone reconciles by hand. So **recommend online payment clearly** — but **at the right moment, which is not first.**
|
|
16
|
+
|
|
17
|
+
**Sequence it after the store exists.** Connecting a provider is a step only the *user* can take, in the platform dashboard: asking for it up front stops your work dead on someone else's action, and even once done there is nothing to verify against — no product to buy, no shipping method, no order. Set the store up first (settings → catalog → shipping), *then* raise payments, as the last thing before the store is usable or before you build checkout. The card gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken while you wait, and nothing is lost by doing it late.
|
|
18
|
+
|
|
19
|
+
When you get there, make it easy:
|
|
20
|
+
|
|
21
|
+
> *"Your store is set up. The last piece is taking money: card payments are already implemented, they just need Stripe connected for this app — that part is yours to do in the dashboard, and then I can place a test order end to end. Want to do that now, or start with manual payments (bank transfer / cash on delivery) for the moment?"*
|
|
22
|
+
|
|
23
|
+
Then:
|
|
24
|
+
|
|
25
|
+
1. **Ask the user to set Stripe up for the app.** On **Base44** that's the platform's own Stripe integration (app dashboard → Integrations → Stripe; it can start in Stripe's test mode and be claimed with a real account later) — *not* an OAuth connector: there is no Stripe connector in Base44's catalog. The platform holds the keys and injects them into backend functions as **`STRIPE_SECRET_KEY`** / **`STRIPE_PUBLISHABLE_KEY`**, which is exactly where `stripe.ts` reads the credential from. Never put keys in the store's data, in code, or in a settings field.
|
|
26
|
+
2. **Redeploy the backend functions — connecting is not enough.** The platform injects `STRIPE_SECRET_KEY` **at deploy time**, so functions that were already deployed keep the environment they started with and cannot see a key added afterwards. `stripe.ts` reads `Deno.env.get("STRIPE_SECRET_KEY")`, so until the functions are redeployed the store reports *no payment provider connected* even though Stripe is set up correctly. This is the single most common "it doesn't work" after connecting.
|
|
27
|
+
|
|
28
|
+
- **Hosted app (no CLI)** — you cannot run a deploy command, so cause one by **changing a file the functions bundle**: edit anything under `base44/shared/commerce/` and every `commerce/*` function redeploys, because `shared/` is bundled into all of them. A one-line comment edit is enough — `shared/commerce/payments.ts` carries a **deploy marker** comment at the top for exactly this: bump its number, save, and the whole set redeploys. (Editing a single function's `entry.ts` redeploys only that function, which is not enough: `payments`, `payment-webhook`, `storefront-checkout`, `storefront-catalog`, `admin-tools` and `admin-refunds` all read the credential.)
|
|
29
|
+
- **CLI** — `npx base44 functions deploy`.
|
|
30
|
+
|
|
31
|
+
3. **Then confirm it landed** — `commerce/admin-tools` → `payment-connector-status` returns `{ connected, provider, gateway_slug }`; the admin's Payments screen shows the same state. Still *not connected*? In order: was the integration actually completed in the dashboard, did the redeploy above really happen, and has the ~60s status cache expired (`onlinePaymentStatus` caches per isolate).
|
|
32
|
+
4. **Nothing else.** The `Credit card` gateway is enabled by default and starts appearing at checkout the moment a provider is connected. No code, no keys, no placeholder to replace. (`commerce/seed-store` is idempotent and never rewrites an existing gateway, so a store seeded *before* this default landed keeps its old `enabled: false` — switch it on in Payments settings.)
|
|
33
|
+
5. **Optional but recommended: register the webhook** (§4) so payments confirm even when the buyer closes the tab.
|
|
34
|
+
6. **Test with the provider's test mode** — place an order, pay, and check the order reaches `processing` with `date_paid` and a payment reference; then try a refund. This is the other reason to do payments *after* the catalog: with products, a shipping method and an address in place, you can prove the whole path works instead of just wiring it.
|
|
35
|
+
|
|
36
|
+
**To stop taking card payments, switch the gateway off** — don't rely on disconnecting the provider in the platform dashboard. Verified on Base44: after disconnecting Stripe there, the injected `STRIPE_SECRET_KEY` was still present *and still accepted by Stripe* (across a redeploy), so the store could genuinely still charge and correctly reported card payment as available. The store-level switch in **Payments settings** is the control that always works, because it's the store's own data.
|
|
37
|
+
|
|
38
|
+
**The user can decline, and that's a legitimate answer.** Some businesses genuinely want invoice, bank transfer or cash on delivery only. If they do:
|
|
39
|
+
|
|
40
|
+
- turn the `Credit card` gateway **off** in Payments settings — that is the whole opt-out;
|
|
41
|
+
- the storefront never offers it, and the admin order page's payment actions sit disabled with *"No payment provider connected"*;
|
|
42
|
+
- say plainly in the checkout UI how payment works (`payment_instructions` from the `place-order` response), and don't describe the store as taking cards anywhere in the copy.
|
|
43
|
+
|
|
44
|
+
Don't leave the in-between state unexplained: the gateway enabled with **no provider connected** is safe (customers never see it — the storefront filters it out live), but the user should know it's waiting on them.
|
|
45
|
+
|
|
46
|
+
## 2. How it works
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
place-order (gateway = card)
|
|
50
|
+
└─ order created `pending` → payment: { status: "requires_payment", checkout_url }
|
|
51
|
+
└─ storefront redirects the customer to checkout_url (provider-hosted page)
|
|
52
|
+
├─ customer returns → commerce/payments `verify` ─┐ both idempotent,
|
|
53
|
+
└─ provider calls → commerce/payment-webhook ─┘ first one wins
|
|
54
|
+
└─ order → `processing`, date_paid set, reference stored, emails + webhooks fire
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Card data never touches this app — the customer pays on the provider's page.
|
|
58
|
+
|
|
59
|
+
**Return URLs — and the route your page actually lives at.** Providers require *absolute* URLs. Resolution, most specific first:
|
|
60
|
+
|
|
61
|
+
1. `success_url` / `cancel_url` — a full page URL per outcome;
|
|
62
|
+
2. `return_url` — **your return page**, used for both outcomes (`payment=success|cancel` distinguishes them). A bare origin (`https://shop`, no path) is instead treated as a base, since there's no page in it;
|
|
63
|
+
3. the store's **Storefront URL** + **Payment return path** (both in general settings: `store_url`, `order_received_path`), or the origin the request came from + that path.
|
|
64
|
+
|
|
65
|
+
Every result carries `order_id`, `order_key` and `payment`, **including URLs you supplied** — a `success_url` without them can't verify anything. Existing query parameters survive. With nothing to build from, the call fails `400 return_url_required` rather than letting the provider answer "Not a valid URL".
|
|
66
|
+
|
|
67
|
+
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they use the store's configured path.
|
|
68
|
+
|
|
69
|
+
### MANDATORY: the payment return page (`/order-received`)
|
|
70
|
+
|
|
71
|
+
**Payment links do not work until you build this page.** Every link comes back to it — checkout, the admin's *Create payment link*, any "pay now" in an email — so without the route a customer pays and lands on a **404**, at the worst possible moment. And because confirming the payment is what moves the order to `processing`, a missing page also means **paid orders sit unpaid forever**.
|
|
72
|
+
|
|
73
|
+
The template ships **no storefront UI**, so the page is yours to build. **If you don't call it `/order-received`, set your route in Settings → General → *Payment return path*** (`settings.general.order_received_path`) — that's what payment links are built from, and a mismatch is a 404 for a paying customer. All the logic is one backend call:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
// GET /order-received?order_id=…&order_key=…&payment=success|cancel
|
|
77
|
+
const params = new URLSearchParams(window.location.search);
|
|
78
|
+
const res = await base44.functions.invoke("commerce/payments", {
|
|
79
|
+
action: "complete-return",
|
|
80
|
+
order_id: params.get("order_id"),
|
|
81
|
+
order_key: params.get("order_key"),
|
|
82
|
+
payment: params.get("payment"), // only a hint — the server decides
|
|
83
|
+
return_url: window.location.origin, // so "pay again" can come back here
|
|
84
|
+
});
|
|
85
|
+
const { state, order, payment_link } = res.data.data;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`complete-return` confirms with the provider, progresses the order when the money landed, and answers with everything the page needs:
|
|
89
|
+
|
|
90
|
+
| Field | Meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `state` | **`paid`** → thank the customer, show the order · **`cancelled`** → they backed out, order saved and unpaid · **`unpaid`** → no payment recorded yet (closed the tab, slow bank, gave up) |
|
|
93
|
+
| `paid`, `already_confirmed`, `status` | the confirmed payment state and the order's new status |
|
|
94
|
+
| `order` | customer-safe order (order number, totals, status) to render |
|
|
95
|
+
| `payment_link` | `{ url, session_id }` while unpaid — wire it to a **Pay now** button; `null` when the store can't take card payment |
|
|
96
|
+
|
|
97
|
+
Rules for whatever you build:
|
|
98
|
+
|
|
99
|
+
- **Call `complete-return` on every visit.** It is idempotent, so refreshes and double-taps are safe, and it is what marks the order paid.
|
|
100
|
+
- **Never treat the URL as proof.** `?payment=success` is editable by anyone; `state` comes from the provider, and a hand-edited URL can never produce `paid`.
|
|
101
|
+
- **Handle all three states**, not just the happy one — `unpaid` is common (bank delays, closed tabs) and needs a *Check again* plus *Pay now*, not an error.
|
|
102
|
+
- **Don't gate it behind login.** Guests pay too; `order_key` in the link is the credential.
|
|
103
|
+
|
|
104
|
+
**Storefront wiring** (the only integration work):**Storefront wiring** (the only integration work):
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const res = await inv("commerce/storefront-checkout", {
|
|
108
|
+
action: "place-order", cart_token, payment_method: "stripe", billing, shipping,
|
|
109
|
+
success_url: `${origin}/order-received?order_id=…&order_key=…`, // optional; a sensible default is used
|
|
110
|
+
cancel_url: `${origin}/checkout`,
|
|
111
|
+
});
|
|
112
|
+
if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
|
|
113
|
+
|
|
114
|
+
// …and on the success page, confirm before showing "paid":
|
|
115
|
+
const { paid, status } = await inv("commerce/payments", { action: "verify", order_id, order_key });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Never mark an order paid from a URL parameter — `verify` asks the provider and is the only honest answer. It is safe to call repeatedly.
|
|
119
|
+
|
|
120
|
+
**Resuming a payment**: `commerce/payments` `create-link` returns a fresh hosted-page URL for any unpaid order (`{ order_id, order_key }` for a customer, or admin auth). That's what powers "pay now" links in the admin and in order-received emails.
|
|
121
|
+
|
|
122
|
+
**Re-issuing after an edit is safe.** Each session is created with an idempotency key derived from the request itself, so an unchanged request (a double-click, a retry) reuses its session while an edited order gets a new one — keying on the order's total alone would break here, since editing an order without moving the total changes the request but not the key. And the previous session is **expired** before the new one is minted, so an old link can't be paid at the total the order used to have.
|
|
123
|
+
|
|
124
|
+
## 3. Payment links from the admin
|
|
125
|
+
|
|
126
|
+
The order page's **Payment** panel shows the method, total, paid state and payment reference, and for **any unpaid order** — with a provider connected — offers:
|
|
127
|
+
|
|
128
|
+
- **Create payment link** — the provider-hosted page for exactly that order's total; copy it to the customer.
|
|
129
|
+
- **Check payment** — re-asks the provider and moves the order on if the money arrived.
|
|
130
|
+
|
|
131
|
+
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was Cash on delivery)…`). That switch matters beyond tidiness: refunds key off `payment_method`, so an order paid by card must say so or it can't be refunded through the provider. The panel warns before it happens.
|
|
132
|
+
|
|
133
|
+
The one refusal is a store that has **turned online payment off** — `400 online_payments_disabled` — because that's the operator declining it, not a missing setup.
|
|
134
|
+
|
|
135
|
+
Both actions are disabled with *"No payment provider connected"* when there's no provider — the capability stays visible so the operator can see what connecting unlocks.
|
|
136
|
+
|
|
137
|
+
## 4. The webhook (recommended)
|
|
138
|
+
|
|
139
|
+
`commerce/payment-webhook` is the second confirmation path: it catches buyers who pay and close the tab before returning.
|
|
140
|
+
|
|
141
|
+
1. Register the function's URL with the provider (Stripe: *Developers → Webhooks*, events `checkout.session.completed` and `payment_intent.succeeded`).
|
|
142
|
+
2. Put the signing secret in the **`PAYMENT_WEBHOOK_SECRET`** environment variable (`STRIPE_WEBHOOK_SECRET` also works).
|
|
143
|
+
3. **On the hosted Base44 platform, also configure the app's Stripe webhook secret in the app settings.** Verified behavior: a request carrying a `stripe-signature` header is gated by the platform *before* any function runs — without that app-level secret every Stripe webhook is answered `400 "No Stripe webhook secret configured for this app"` and never reaches `commerce/payment-webhook`. Use the same secret in both places.
|
|
144
|
+
|
|
145
|
+
**The payload is never believed on its own**, which is what makes this safe with or without a secret:
|
|
146
|
+
|
|
147
|
+
- **With a signing secret**, the signature is verified here (provider scheme, timestamp tolerance so a captured request can't be replayed) and the event's "paid" is then trusted — one fewer round trip.
|
|
148
|
+
- **Without one** — the case on Base44, where a function's environment is fixed (`BASE44_APP_ID`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`) and apps can't add their own — the body counts only as *a nudge naming an order*. Whether money arrived is then asked of the provider over our own authenticated API call. A forged webhook achieves nothing beyond making the store re-check an order; it can never mark one paid.
|
|
149
|
+
|
|
150
|
+
Either way the event must carry the `order_key` matching the order it names, and any session id in it is fetched and checked to have been opened for *that* order (the provider's own copy of `order_id`/`order_key`, put there at session creation). So a genuine payment for one order can never be replayed against another — a €5 order settled for real does not confirm a €500 one. A mismatch answers `409 session_order_mismatch` (and is ignored, with a 200, on the webhook so the provider stops retrying).
|
|
151
|
+
|
|
152
|
+
So the webhook is worth registering either way. It remains a **resilience upgrade, not a prerequisite**: the return page confirms payments on its own.
|
|
153
|
+
|
|
154
|
+
## 5. Refunds
|
|
155
|
+
|
|
156
|
+
`commerce/admin-refunds` `create` with `refund_payment: true` sends the money back through the provider, then records the refund — **in that order**, so a failed provider refund never leaves a phantom refund behind. The response carries `gateway_refund: { provider, id, status, amount }` and the refund record's `refunded_payment` is true only when the provider really refunded. An order that wasn't paid online rejects `refund_payment` with `400 no_online_payment`: that money moved outside the store and has to go back the same way.
|
|
157
|
+
|
|
158
|
+
The admin's refund panel offers *"Send the money back through the payment provider"* only when the order was actually paid online and a provider is connected.
|
|
159
|
+
|
|
160
|
+
## 6. Using a different provider
|
|
161
|
+
|
|
162
|
+
The payment utility exists for this. **Write an adapter and change one line:**
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// base44/shared/commerce/payments.ts
|
|
166
|
+
const paypalAdapter: PaymentAdapter = { id: "paypal", label: "PayPal", gatewaySlug: "paypal", /* … */ };
|
|
167
|
+
export const ACTIVE_PROVIDER: PaymentAdapter = paypalAdapter; // ← was stripeAdapter
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`PaymentAdapter` is the whole contract: `connection`, `createSession`, `retrieveSession`, `refund`, `verifyWebhook`, `parseWebhookEvent`. Implement those against the provider's API (model `stripe.ts`, which is the reference implementation — including how it finds its credential: an injected secret first, a host connector second) and **nothing else changes** — checkout, the payment/webhook functions, refunds, the admin panel and the storefront gateway filter all go through the utility.
|
|
171
|
+
|
|
172
|
+
Then check the remaining touchpoints, all of them data or copy rather than logic:
|
|
173
|
+
|
|
174
|
+
- **The gateway record** (`commerce.PaymentGateway`, slug `stripe` by default): either keep the slug and just change `title`/`method_title`, or create a record whose slug matches your adapter's `gatewaySlug` and disable the old one. `ACTIVE_PROVIDER.gatewaySlug` is what makes a gateway "the online one".
|
|
175
|
+
- **`grep -rn "stripe" base44/ src/`** — after swapping the binding, every remaining hit should be the adapter, the seeded gateway record, or the `stripe-signature` header fallback in the webhook. If it's anywhere else, that's a leak worth fixing.
|
|
176
|
+
- **The webhook secret** env var stays `PAYMENT_WEBHOOK_SECRET`, and your `verifyWebhook` decides how to check it.
|
|
177
|
+
|
|
178
|
+
Keeping several providers live at once (card *and* PayPal, say) means going beyond a single `ACTIVE_PROVIDER`: give each its own gateway slug and resolve the adapter per order's `payment_method`. The utility is the place to add that map — callers won't need to change.
|
|
179
|
+
|
|
180
|
+
## 7. The storefront must render payment options from settings — never a hardcoded list
|
|
181
|
+
|
|
182
|
+
The available payment methods are **data**, owned by the admin (Payments settings → `commerce.PaymentGateway`). A storefront that hardcodes "Card / Bank transfer / COD" will silently disagree with the store the moment an admin toggles a gateway, reorders them, or edits a title.
|
|
183
|
+
|
|
184
|
+
Rules for any checkout UI you build:
|
|
185
|
+
|
|
186
|
+
- **Fetch, don't assume.** `commerce/storefront-catalog` → `get-store-info` returns `payment_gateways`, filtered to `enabled: true`, sorted by the admin's `order`, and with any online gateway **dropped while no provider is connected** — so what you render is always payable. Each entry is `{ slug, title, description, online }`.
|
|
187
|
+
- **Use the admin's copy.** Show `title`/`description` as the customer-facing labels instead of your own strings, and key your logic off `slug` (`online: true` marks the one that redirects to a hosted page).
|
|
188
|
+
- **Render the zero-state honestly.** An empty `payment_gateways` means checkout cannot complete; say so rather than showing a dead button.
|
|
189
|
+
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate rules like `cod`'s `enable_for_methods` / `enable_for_virtual`. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
|
|
190
|
+
- **Re-fetch rather than cache hard.** An admin can enable, disable or reorder gateways at any time; a long-lived cached list is how a storefront drifts out of sync with the store.
|
|
191
|
+
- **Show `payment_instructions` from the response** for manual flows (`bacs`/`cheque`/`cod`) rather than hardcoding bank details in the UI.
|
|
192
|
+
|
|
193
|
+
## 8. Never hardcode payment availability
|
|
194
|
+
|
|
195
|
+
Whether the store can take a card is a **live fact about the connector**, not a constant. Everything derives it:
|
|
196
|
+
|
|
197
|
+
- backend — `onlinePaymentStatus(sr)` from the payment utility. It doesn't just look for a key, it **verifies** it with the provider (cheapest authenticated call, answer cached ~60s): a credential that has been disconnected, rotated or revoked lingers in a function's environment until the next deploy, so presence alone would advertise card payment the store can no longer take. The same deploy-time injection is why a *newly* connected provider stays invisible until the functions are redeployed (§2, step 2). Expect up to a minute for a change to show. A *rejected* credential means not connected; a network blip does **not** flip a working store to "no payments" while a good answer is still cached;
|
|
198
|
+
- admin — the `usePaymentProvider()` hook (`commerce/admin-tools` → `payment-connector-status`);
|
|
199
|
+
- storefront — the filtered `payment_gateways` list.
|
|
200
|
+
|
|
201
|
+
So do **not** ship a permanent "payments not set up" banner: it disappears by itself the moment a provider is connected. And never surface a provider name in a failure state — *"No payment provider connected"* is what an operator needs to read.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Rendering products: list view and product page
|
|
2
|
+
|
|
3
|
+
What to show for a product, and **which view can show it**. Both storefront surfaces render the same catalog record from a different call:
|
|
4
|
+
|
|
5
|
+
| View | Call | Returns |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Listing / grid / search results / tag & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
|
|
8
|
+
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells, grouped_products }` |
|
|
9
|
+
|
|
10
|
+
Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). Variant selection mechanics (axes, resolving a selection to a variation, unavailable combinations): [`storefront-product-page.md`](./storefront-product-page.md).
|
|
11
|
+
|
|
12
|
+
You choose what belongs in each view — but you can only render what the call returns. The table below is the boundary.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. Field availability
|
|
17
|
+
|
|
18
|
+
A listing **row** is the product record itself (minus paywalled fields), plus resolved `tags`. `get-product` adds everything that needs a second read.
|
|
19
|
+
|
|
20
|
+
| Data | `list-products` row | `get-product` | Notes |
|
|
21
|
+
|---|---|---|---|
|
|
22
|
+
| `id`, `name`, `slug`, `type`, `status` | ✅ | ✅ | |
|
|
23
|
+
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | For `variable`, the parent `price` is a starting point, not the truth — see §3 |
|
|
24
|
+
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. `images` can be **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case) |
|
|
25
|
+
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
26
|
+
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
27
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the raw axes/spec array |
|
|
28
|
+
| **`tags`** (resolved `{id, name, slug}`) | ✅ | ✅ | `get-product` returns the full tag records; rows carry the short form |
|
|
29
|
+
| `tag_ids`, `category_ids` | ✅ | ✅ | Raw ids |
|
|
30
|
+
| **`categories`** (resolved) | ❌ *ids only* | ✅ | See §5 to add |
|
|
31
|
+
| **`variations[]`** (per-variation price/stock/image/attributes) | ❌ | ✅ | The reason a variable product can't be fully priced from a row |
|
|
32
|
+
| **`reviews`** (paged items + `average_rating`/`rating_count`) | ❌ | ✅ | Row still has the aggregate numbers |
|
|
33
|
+
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
34
|
+
| **`grouped_products`** (children of a grouped product) | ❌ | ✅ | |
|
|
35
|
+
| `downloads[]`, `download_limit`, `download_expiry`, `purchase_note` | ❌ | ❌ | **Never** exposed publicly — paywalled/post-purchase. Gated by `commerce/storefront-account` `get-download` |
|
|
36
|
+
|
|
37
|
+
## 2. Tags — render them in **both** views
|
|
38
|
+
|
|
39
|
+
Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Generated storefronts routinely omit tags entirely. Don't.
|
|
40
|
+
|
|
41
|
+
- **Listing rows carry resolved `tags`** (`{id, name, slug}`), so a card can render them with no extra call. One or two chips per card is useful ("New", "Bundle"); more is noise. Hide the block when the array is empty — never a dangling "Tags:" label.
|
|
42
|
+
- **On the product page**, render `product.tags` (the top-level `tags` array, with `description` and `count` available) as chips near the metadata, visually lighter than the category breadcrumb.
|
|
43
|
+
- **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label. Keep the tag in the URL (`/shop?tag=<slug>` or `/tag/<slug>`) so the page is shareable and survives reload; resolve slug → id with `list-tags`.
|
|
44
|
+
- **Offer tags as a listing filter** from `list-tags`, which hides tags no published product carries and gives a `count` for labels like "Gift (12)". `tag_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
45
|
+
- **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as heading and `description` as intro copy when present.
|
|
46
|
+
- Don't put tags in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a tag.
|
|
47
|
+
|
|
48
|
+
## 3. Product types in each view
|
|
49
|
+
|
|
50
|
+
| `product.type` | Card | Product page |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `simple` | price, **Add to cart** straight from the card if you want one | single **Add to cart** — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
|
|
53
|
+
| `variable` | price **range or `From €19`** (see below), never selectors — link through | one selector per attribute: [`storefront-product-page.md`](./storefront-product-page.md) |
|
|
54
|
+
| `grouped` | link through, no cart action | not purchasable itself: render `grouped_products` as children with their own quantity inputs, each added as its own cart line |
|
|
55
|
+
| `external` | link through | `product.button_text` linking to `product.external_url`; no cart interaction |
|
|
56
|
+
|
|
57
|
+
Downloadable/virtual products are `simple`/`variable` with `downloadable: true` — see [`media-and-downloads.md`](./media-and-downloads.md).
|
|
58
|
+
|
|
59
|
+
### A variable product's parent `price` is not its price
|
|
60
|
+
|
|
61
|
+
The backend rolls **stock** up from variations to the parent, but **not price** — a variable parent's `price` is only whatever was set on the parent record, often nothing. A row has no `variations`, so a card cannot compute the real range; show `From {product.price}` or a range, never the parent price presented as *the* price, and never `€0`.
|
|
62
|
+
|
|
63
|
+
This is a **data** problem before it is a UI one: `list-products` sorts and filters on `product.price ?? 0`, so a variable product with no parent price **sorts as free and drops out of every price-range filter**. Fix it in the catalog — set the parent's `regular_price` to the lowest variation price (purchasing is unaffected: a variable line always prices from its variation). Then cards, sorting and filters all agree.
|
|
64
|
+
|
|
65
|
+
## 4. Sensible defaults per view
|
|
66
|
+
|
|
67
|
+
**Card:** image, name, price (or range), sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two tags. Link the whole card to the product page.
|
|
68
|
+
|
|
69
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, tags, reviews, then upsells/cross-sells.
|
|
70
|
+
|
|
71
|
+
## 5. Adding a `get-product`-only field to the list view
|
|
72
|
+
|
|
73
|
+
If a customer asks for something the row doesn't carry — resolved categories are the common one — resolve it in the listing call rather than firing `get-product` per card (that's N requests for one grid).
|
|
74
|
+
|
|
75
|
+
`list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const pageItems = await withTags(sr, products.slice(start, start + perPage).map(publicProduct));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`withTags` reads the taxonomy **once per request** and maps ids on 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 the fields the UI renders; a listing payload is served on every browse.
|
|
82
|
+
|
|
83
|
+
What not to do:
|
|
84
|
+
|
|
85
|
+
- Don't call `get-product` per card.
|
|
86
|
+
- Don't add `variations` to every row — that is the heaviest read in the catalog and a grid does not need it. If cards must show real price ranges, add a precomputed `price_min`/`price_max` instead.
|
|
87
|
+
- Don't relax entity RLS to read the catalog from the client: `commerce.Product` and the taxonomy entities are admin-only by design ([`guest-access-security.md`](./guest-access-security.md)). Widen the function's response instead.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Scheduled work (workflows)
|
|
2
|
+
|
|
3
|
+
Base44 **has** a scheduler — the recurring maintenance below is run by **scheduled workflows**, and the Base44 agent can create them for you. **This template ships no workflow/schedule files on purpose**: don't look for them under `base44/` and don't add them here — create the workflows directly in the target app.
|
|
4
|
+
|
|
5
|
+
**Instruct the agent (or set up yourself): create one scheduled workflow per row below.** Each workflow does one thing — invoke the given function action on the chosen cadence:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
await base44.functions.invoke("commerce/admin-tools", { action: "clear-abandoned-carts", older_than_days: 2 });
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Workflow to create | What it does | Action to invoke | Payload | Suggested cadence |
|
|
12
|
+
|---|---|---|---|---|
|
|
13
|
+
| Release expired stock holds | Frees stock held by unpaid orders past `hold_stock_minutes`. `commerce/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 |
|
|
14
|
+
| 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 |
|
|
15
|
+
| 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 |
|
|
16
|
+
|
|
17
|
+
**Optional — drift repair.** Denormalized counters can drift without transactions (see [`limits-and-performance.md`](./limits-and-performance.md)). If you want them self-healing, add a nightly/weekly workflow that calls the `commerce/admin-tools` recount actions: `recount-terms`, `recount-coupon-usage`, `recalculate-customer-stats-all`, `regenerate-download-permissions`.
|
|
18
|
+
|
|
19
|
+
Every action above is guarded by `requireAdmin()`, so each scheduled workflow must run with **admin privileges** (an admin identity / service context), not as an anonymous caller.
|