@base44/app-plugin-commerce 0.1.3 → 0.1.4
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 +8 -8
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +107 -35
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +47 -35
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +21 -4
- package/skills/commerce/SKILL.md +6 -5
- package/skills/commerce/docs/api-admin.md +16 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +22 -25
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* opportunistically since Base44 has no cron)
|
|
10
10
|
*/
|
|
11
11
|
import { sendStockEmail } from "./emails.ts";
|
|
12
|
+
import { isVariable } from "./products.ts";
|
|
12
13
|
import { getSettings } from "./settings.ts";
|
|
13
14
|
|
|
14
15
|
export interface PurchasableResult {
|
|
@@ -54,15 +55,12 @@ export function checkPurchasable(product: any, variation: any | undefined, qty:
|
|
|
54
55
|
if (!product || product.status !== "publish") {
|
|
55
56
|
return { ok: false, code: "not_published", error: "This product is not available." };
|
|
56
57
|
}
|
|
57
|
-
if (product
|
|
58
|
+
if (isVariable(product) && !variation) {
|
|
58
59
|
return { ok: false, code: "variation_required", error: "Please choose product options." };
|
|
59
60
|
}
|
|
60
61
|
if (variation && variation.status && variation.status !== "publish") {
|
|
61
62
|
return { ok: false, code: "not_published", error: "This product option is not available." };
|
|
62
63
|
}
|
|
63
|
-
if (product.type === "external") {
|
|
64
|
-
return { ok: false, code: "not_purchasable", error: "This product can only be purchased on an external site." };
|
|
65
|
-
}
|
|
66
64
|
const src = variation ?? product;
|
|
67
65
|
if (src.price === undefined || src.price === null) {
|
|
68
66
|
return { ok: false, code: "not_purchasable", error: "This product cannot be purchased (no price set)." };
|
|
@@ -73,15 +73,13 @@ function effectiveTax(product: any, variation?: any): { tax_class: string; tax_s
|
|
|
73
73
|
function resolveTaxAddress(input: TotalsInput): any {
|
|
74
74
|
const basedOn = getSetting(input.settings, "tax", "tax_based_on", "shipping");
|
|
75
75
|
if (basedOn === "billing") return input.billing ?? input.shipping_address ?? {};
|
|
76
|
-
if (basedOn === "base") return getSetting(input.settings, "general", "address", {});
|
|
77
76
|
return input.shipping_address ?? input.billing ?? {}; // "shipping" default
|
|
78
77
|
}
|
|
79
78
|
|
|
80
79
|
/** Run the full totals pipeline. Pure aside from reading its inputs. */
|
|
81
80
|
export function calculateTotals(input: TotalsInput): TotalsResult {
|
|
82
81
|
const settings = input.settings ?? {};
|
|
83
|
-
const
|
|
84
|
-
const pricesIncludeTax = taxesEnabled && !!getSetting(settings, "tax", "prices_include_tax", false);
|
|
82
|
+
const pricesIncludeTax = !!getSetting(settings, "tax", "prices_include_tax", false);
|
|
85
83
|
const taxRates = input.taxRates ?? [];
|
|
86
84
|
const taxAddress = resolveTaxAddress(input);
|
|
87
85
|
|
|
@@ -89,7 +87,7 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
|
|
|
89
87
|
const lines = (input.items || []).map((it) => {
|
|
90
88
|
const src = it.variation ?? it.product;
|
|
91
89
|
const { tax_class, tax_status } = effectiveTax(it.product, it.variation);
|
|
92
|
-
const rates =
|
|
90
|
+
const rates = tax_status === "taxable" ? matchTaxRates(taxRates, taxAddress, tax_class) : [];
|
|
93
91
|
let unitPrice = Number(src.price ?? it.product?.price ?? 0);
|
|
94
92
|
let subtotal = round2(unitPrice * it.quantity);
|
|
95
93
|
if (pricesIncludeTax && rates.length) {
|
|
@@ -126,9 +124,8 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
|
|
|
126
124
|
const itemsSubtotal = round2(lines.reduce((a, l) => a + l.subtotal, 0));
|
|
127
125
|
|
|
128
126
|
// ── 2. coupons ───────────────────────────────────────────────────────────
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const couponLines = applyCoupons(lines, coupons, settings);
|
|
127
|
+
const coupons = input.coupons ?? [];
|
|
128
|
+
const couponLines = applyCoupons(lines, coupons);
|
|
132
129
|
for (const l of lines) l.total = round2(l.subtotal - l.discount);
|
|
133
130
|
const discountTotal = round2(lines.reduce((a, l) => a + l.discount, 0));
|
|
134
131
|
const itemsAfterDiscount = round2(lines.reduce((a, l) => a + l.total, 0));
|
|
@@ -191,41 +188,39 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
|
|
|
191
188
|
agg.set(key, cur);
|
|
192
189
|
};
|
|
193
190
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
for (const t of applied) bump(t.rate, 0, t.amount);
|
|
228
|
-
}
|
|
191
|
+
for (const l of lines) {
|
|
192
|
+
if (!l.rates.length) continue;
|
|
193
|
+
const subApplied = applyRates(l.subtotal, l.rates);
|
|
194
|
+
const totApplied = applyRates(l.total, l.rates);
|
|
195
|
+
l.subtotal_tax = sumTax(subApplied);
|
|
196
|
+
l.total_tax = sumTax(totApplied);
|
|
197
|
+
l.taxes = totApplied.map((t, i) => ({
|
|
198
|
+
rate_id: t.rate.id ?? "",
|
|
199
|
+
total: t.amount,
|
|
200
|
+
subtotal: subApplied[i]?.amount ?? 0,
|
|
201
|
+
}));
|
|
202
|
+
for (const t of totApplied) bump(t.rate, t.amount, 0);
|
|
203
|
+
}
|
|
204
|
+
for (const fee of feeLines) {
|
|
205
|
+
if (fee.tax_status !== "taxable") continue;
|
|
206
|
+
const rates = matchTaxRates(taxRates, taxAddress, fee.tax_class);
|
|
207
|
+
const applied = applyRates(fee.total, rates);
|
|
208
|
+
fee.total_tax = sumTax(applied);
|
|
209
|
+
fee.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
|
|
210
|
+
for (const t of applied) bump(t.rate, t.amount, 0);
|
|
211
|
+
}
|
|
212
|
+
if (shippingLines.length) {
|
|
213
|
+
// shipping_tax_class "inherit" = first taxable line's class (intended behavior)
|
|
214
|
+
const cfg = getSetting(settings, "tax", "shipping_tax_class", "inherit");
|
|
215
|
+
const cls = cfg === "inherit"
|
|
216
|
+
? (lines.find((l) => l.tax_status === "taxable")?.tax_class ?? "standard")
|
|
217
|
+
: cfg;
|
|
218
|
+
const rates = matchTaxRates(taxRates, taxAddress, cls).filter((r) => r.shipping !== false);
|
|
219
|
+
for (const sl of shippingLines) {
|
|
220
|
+
const applied = applyRates(sl.total, rates);
|
|
221
|
+
sl.total_tax = sumTax(applied);
|
|
222
|
+
sl.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
|
|
223
|
+
for (const t of applied) bump(t.rate, 0, t.amount);
|
|
229
224
|
}
|
|
230
225
|
}
|
|
231
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
package/scripts/install.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*
|
|
27
27
|
* This is only the static part of the install. The remaining steps live in
|
|
28
28
|
* ../skills/commerce/installation-guidelines.md (deps, deploy, seeding) and
|
|
29
|
-
* ../skills/commerce/post-installation.md (mounting the /admin route, admin
|
|
29
|
+
* ../skills/commerce/post-installation.md (mounting the /store-admin route, admin
|
|
30
30
|
* role, AGENTS.md registration); day-2 guidance starts at
|
|
31
31
|
* ../skills/commerce/SKILL.md. The skill folder carries all of this
|
|
32
32
|
* documentation into the app.
|
|
@@ -93,6 +93,22 @@
|
|
|
93
93
|
// longer ships is listed here and removed on install.
|
|
94
94
|
const RETIRED = [
|
|
95
95
|
["skills", "commerce", "references", "stripe-payments.md"], // → references/online-payments.md
|
|
96
|
+
// Attributes and their values are edited in the Attributes section of the
|
|
97
|
+
// Price & Inventory tab, tags in the product sidebar; the product type and the
|
|
98
|
+
// Advanced tab are gone entirely.
|
|
99
|
+
["src", "commerce", "admin", "pages", "products", "Attributes.jsx"],
|
|
100
|
+
["src", "commerce", "admin", "pages", "products", "AttributeTerms.jsx"],
|
|
101
|
+
["src", "commerce", "admin", "pages", "products", "Tags.jsx"],
|
|
102
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "ExternalTab.jsx"],
|
|
103
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "AdvancedTab.jsx"],
|
|
104
|
+
// General + Inventory + Variations merged into PriceInventoryTab.jsx.
|
|
105
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "GeneralTab.jsx"],
|
|
106
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "InventoryTab.jsx"],
|
|
107
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "VariationsTab.jsx"],
|
|
108
|
+
// Attributes moved into Price & Inventory; weight/dimensions/shipping class
|
|
109
|
+
// are per variant, so the product-level Shipping tab has nothing left.
|
|
110
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "AttributesTab.jsx"],
|
|
111
|
+
["src", "commerce", "admin", "pages", "products", "components", "tabs", "ShippingTab.jsx"],
|
|
96
112
|
];
|
|
97
113
|
let retiredRemoved = 0;
|
|
98
114
|
for (const parts of RETIRED) {
|
|
@@ -147,10 +163,11 @@
|
|
|
147
163
|
" 1. No deps to add: sonner, recharts and react-markdown ship with the default\n" +
|
|
148
164
|
" Base44 template — check package.json and npm i only what is truly missing;\n" +
|
|
149
165
|
" do not re-install packages already listed as dependencies\n" +
|
|
150
|
-
' 2. Mount the admin router: <Route path="/admin/*" element={<AdminApp />} />\n' +
|
|
166
|
+
' 2. Mount the admin router: <Route path="/store-admin/*" element={<AdminApp />} />\n' +
|
|
151
167
|
" and implement the payment return page /order-received (post-installation.md)\n" +
|
|
152
|
-
" 3. Grant your user the admin role, then settle the store's data —
|
|
153
|
-
"
|
|
168
|
+
" 3. Grant your user the admin role, then settle the store's data — both seeding paths\n" +
|
|
169
|
+
" need the app's name as store_name — generate a catalog,\n" +
|
|
170
|
+
" seed the demo data, or initialize defaults from /store-admin (post-installation.md §2)\n" +
|
|
154
171
|
" 4. Register the template + skill in AGENTS.md (see skills/commerce/post-installation.md)\n" +
|
|
155
172
|
" 5. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
|
|
156
173
|
);
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: commerce
|
|
3
|
-
description: Base44 Commerce template — 24 commerce.* entities, 16 commerce/* backend functions (storefront + admin APIs + online payments), the shared commerce engine under base44/shared/commerce/, the
|
|
3
|
+
description: 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 Management UI mounted at /store-admin, and the commerce/StoreAdmin agent (admin copilot). Read before working on store features — the admin UI, storefront building, Stripe wiring, scheduled maintenance, emails, webhooks, downloads, or scaling limits.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Commerce
|
|
@@ -24,19 +24,19 @@ If the template was just installed (or you are installing it right now), read [`
|
|
|
24
24
|
|
|
25
25
|
Agents keep shipping storefronts that miss these, and each one breaks buying outright. Do them; the details are in [`docs/api-storefront.md`](./docs/api-storefront.md) and [`references/storefront-product-page.md`](./references/storefront-product-page.md).
|
|
26
26
|
|
|
27
|
-
1. **
|
|
27
|
+
1. **Products with variants need one selector per attribute — and the selection must resolve to a variation.** `get-product` gives `product.attributes` (every entry is an axis — there is no product type) and `variations` (the combinations). Render a control per axis, never a list of combinations, then send the resolved `variation_id`:
|
|
28
28
|
```js
|
|
29
29
|
import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
30
30
|
const view = resolveSelection(product, variations, selection); // axes, availability, price, addToCart
|
|
31
31
|
await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity });
|
|
32
32
|
```
|
|
33
|
-
`add-item` **rejects a
|
|
33
|
+
`add-item` **rejects a product with attributes unless it gets a `variation_id`** (`400 variation_required`), so a page that ignores this cannot sell anything.
|
|
34
34
|
|
|
35
35
|
2. **Checkout must present shipping options and send a choice.** After `set-shipping-address`, read `shipping_status` on the cart: `auto_selected` (one option, already applied) · `chosen` · `choice_required` → **you must show `available_shipping_methods` and call `choose-shipping-method`** · `none_available` → say so. `place-order` refuses with `400 shipping_method_required` until then — that is not a bug to work around.
|
|
36
36
|
|
|
37
37
|
3. **Take card payments, and build `/order-received`.** (Ask the user to connect the provider *after* the store is set up — it's their step in the dashboard, and a catalog plus a shipping method is what makes a test payment provable.) Choosing the online gateway returns `payment.checkout_url` — redirect there. Every payment link comes back to `/order-received`, which **you must implement**: call `commerce/payments` `complete-return` with the query params and render its `state`. Without that page a customer pays into a 404 and the order is never marked paid.
|
|
38
38
|
|
|
39
|
-
4. **Never advertise what isn't configured.** "Free shipping over €150" must come from a real `free_shipping` zone method
|
|
39
|
+
4. **Never advertise what isn't configured.** "Free shipping over €150" must come from a real `free_shipping` zone method. Zones are admin-only data, so a storefront cannot read them: the live answer is the cart's `available_shipping_methods` after `set-shipping-address`, and `shipping-promos.js` normalizes the rules wherever the records *are* in hand. No rule means no banner.
|
|
40
40
|
|
|
41
41
|
All functions return the envelope `{ success, data }` (or `{ success, error, code }`); with the SDK the body is on `res.data`:
|
|
42
42
|
|
|
@@ -51,8 +51,9 @@ Open the matching file under `skills/commerce/references/` only when a task touc
|
|
|
51
51
|
|
|
52
52
|
| Topic | Read when the task involves | Reference |
|
|
53
53
|
|---|---|---|
|
|
54
|
-
| Product rendering (list + page) | what to show in a product grid vs. the product page, field availability across `list-products`/`get-product`, **tags in both views**,
|
|
54
|
+
| Product rendering (list + page) | what to show in a product grid vs. the product page, field availability across `list-products`/`get-product`, **tags in both views**, variant pricing on cards, adding a page-only field to the listing call | [`references/product-render.md`](./references/product-render.md) |
|
|
55
55
|
| Variant selection | attribute-level selectors, resolving a selection to a variation, availability states, incomplete-selection pricing, add-to-cart contract | [`references/storefront-product-page.md`](./references/storefront-product-page.md) |
|
|
56
|
+
| Admin product form | changing the product editor — its tabs are **Price & Inventory** (tax, then the attributes, then a row per variant, or a single *Base price* row when there are none), **Modifiers** (`meta_data`), **Downloads**, **Linked products**. Variants reconcile from the attribute values automatically: no generate step, no per-variant delete. Weight, dimensions and shipping class are per variant. | [`references/admin-product-form.md`](./references/admin-product-form.md) |
|
|
56
57
|
| Online payments | **any storefront or checkout work** — card payments ship implemented (hosted page, payment links, signed webhook, refunds) behind a provider-neutral utility wired to Stripe; connect a provider to go live, or implement one adapter to use another | [`references/online-payments.md`](./references/online-payments.md) |
|
|
57
58
|
| Scheduled work | recurring maintenance — stock-hold release, abandoned-cart cleanup, webhook-log pruning, counter-drift repair | [`references/scheduled-work.md`](./references/scheduled-work.md) |
|
|
58
59
|
| Emails | transactional order emails, per-type overrides, deliverability, the email log | [`references/emails.md`](./references/emails.md) |
|
|
@@ -14,15 +14,15 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
|
|
|
14
14
|
| commerce.Coupon | direct | **`commerce/admin-coupons`** | code normalization/uniqueness, webhooks |
|
|
15
15
|
| commerce.Customer | direct | **`commerce/admin-customers`** | email uniqueness, invite/link, stats |
|
|
16
16
|
| commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
|
|
17
|
-
| commerce.ProductCategory, commerce.ProductTag | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | slug uniqueness
|
|
18
|
-
| commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) |
|
|
17
|
+
| commerce.ProductCategory, commerce.ProductTag | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; tag get-or-create by name |
|
|
18
|
+
| commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
|
|
19
19
|
| commerce.ShippingClass | direct | **direct CRUD** | plain config; RLS enforces admin-only |
|
|
20
20
|
| commerce.TaxClass, commerce.TaxRate, commerce.ShippingZone, commerce.ShippingZoneMethod, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
|
|
21
21
|
| commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
|
|
22
22
|
| commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
|
|
23
23
|
| commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
**Every entity is admin-only on read *and* write** — including the catalog. Storefront access goes exclusively through the `commerce/storefront-*` functions, which run with the service role and project only what is safe; a direct entity read or write from a non-admin is rejected by the backend regardless of the UI. See [`references/guest-access-security.md`](../references/guest-access-security.md).
|
|
26
26
|
|
|
27
27
|
## Invocation & envelope
|
|
28
28
|
|
|
@@ -45,24 +45,26 @@ Success: `{ success: true, data }`. Failure: `{ success: false, error, code }` w
|
|
|
45
45
|
Actions: `save` · `delete` · `batch` · `duplicate` · `set-stock` · `search` · `save-term` · `delete-term` · `list-terms`
|
|
46
46
|
|
|
47
47
|
- **`save`** — `{ product, variations? }`. Upserts the product (create if no `id`); when `variations` is provided, diffs them (create/update/delete-missing).
|
|
48
|
-
> **
|
|
48
|
+
> **Selling variants takes both of these in *this* call**: the attribute listed **on the product** as `attributes: [{ attribute_id, name, position, options: [...] }]`, plus a `variations` array with one entry per stocked combination (each `{ attributes: [{ attribute_id, name, option }], sku, regular_price, manage_stock: "yes", stock_quantity, status: "publish" }`) and `default_attributes` for the pre-selected combination. There is **no `type` field**: carrying attributes is what makes a product sell variants, so a `commerce.ProductAttribute` record on its own changes nothing — and a product listing an attribute with no variations cannot be added to a cart at all (`400 variation_required`). Descriptive properties belong in `meta_data`, not `attributes`. **Don't set the parent's price** — `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save, which is what makes catalog cards, price sorting and price filters agree. Enforces SKU + slug uniqueness across products *and* variations (auto-suffixes slug on collision; `duplicate_sku` on SKU clash). Derives `price`/`on_sale` from the sale window and `stock_status` when stock is managed; updates category/tag `count`; rolls parent stock **and price** up when the product has attributes; fires `product.created`/`product.updated`. → `{ product, variations }`.
|
|
49
49
|
- **`delete`** — `{ id }`. Cascades variations, decrements counts, fires `product.deleted`.
|
|
50
50
|
- **`batch`** — `{ create?: [], update?: [], delete?: [] }` (≤100 total) → per-item results.
|
|
51
51
|
- **`duplicate`** — `{ id }` → new draft copy (name "(Copy)", suffixed SKU, reset sales/ratings) incl. variations.
|
|
52
52
|
- **`set-stock`** — `{ id, variation_id?, quantity }`. Sets quantity, re-derives status, sends low/out-of-stock admin emails on threshold crossings.
|
|
53
|
-
- **`search`** — `{ q?, category_id?,
|
|
53
|
+
- **`search`** — `{ q?, category_id?, stock_status?, status?, sort?, limit?, skip? }` → `{ rows, has_next }`. `category_id` includes descendant categories.
|
|
54
54
|
- **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"tag"` | `"attribute"` | `"attribute-term"`, and `term` takes the fields for that one:
|
|
55
55
|
|
|
56
56
|
| taxonomy | entity | `term` fields |
|
|
57
57
|
|---|---|---|
|
|
58
58
|
| `category` | commerce.ProductCategory | `id?, name, slug?, description?, parent_id?, image?, menu_order?` |
|
|
59
|
-
| `tag` | commerce.ProductTag | `id?, name
|
|
60
|
-
| `attribute` | commerce.ProductAttribute | `id?, name,
|
|
61
|
-
| `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name,
|
|
59
|
+
| `tag` | commerce.ProductTag | `id?, name` |
|
|
60
|
+
| `attribute` | commerce.ProductAttribute | `id?, name, code?, order?` |
|
|
61
|
+
| `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name, order?` |
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
Only a category has a slug, derived from its name and made unique; its `parent_id` pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). An attribute's **`code`** is derived from the name and made unique — it is the key a storefront filter URL should carry. Creating a **tag** is get-or-create: a name that already exists case-insensitively returns the existing record instead of splitting the tag in two. Renaming an **attribute value** rewrites `attributes[].options` and `default_attributes` on every product using it, and the matching `option` on their variations — products store a value by name, so the rename would otherwise orphan them.
|
|
64
|
+
|
|
65
|
+
This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`tag_ids`/`attributes[].attribute_id` reference — assigning an id is useless if the record can't be created. **A product's `commerce.ProductAttribute` and its values must exist first**, so create those, then `save` the product with `attributes[]`/`variations[]`.
|
|
66
|
+
- **`delete-term`** — `{ taxonomy, id, detach? }` → `{ deleted, detached, terms_deleted }`. Deleting an **attribute** always deletes its terms (a term outliving its attribute is unreachable); `detach: true` additionally strips the attribute from every product's `attributes[]`. For a category or tag, products keep the id by default (the storefront skips ids that no longer resolve); `detach: true` strips it from every product first. **For an attribute *value* `detach` is a no-op** — deleting a value leaves its name in every product's `attributes[].options` and leaves the variations that use it in place, so remove the value from the products first (or expect variants the storefront can no longer resolve).
|
|
67
|
+
- **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`, tags by `name`; `attribute_id` filters values to one attribute. Use it to reuse an existing record instead of creating a duplicate.
|
|
66
68
|
|
|
67
69
|
## commerce/admin-orders
|
|
68
70
|
|
|
@@ -149,7 +151,7 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
|
|
|
149
151
|
| `top-sellers` | `{ date_min?, date_max?, limit? }` | `{ rows: [{ product_id, name, sku, quantity, net_revenue }] }` |
|
|
150
152
|
| `stock` | — | `{ low_stock: [...], out_of_stock: [...] }` |
|
|
151
153
|
| `orders-totals` | — | `{ [status]: count }` |
|
|
152
|
-
| `products-totals` | — | `{ total, by: { [
|
|
154
|
+
| `products-totals` | — | `{ total, by: { [status]: count } }` |
|
|
153
155
|
| `customers-totals` | — | `{ total, guests, registered, paying }` |
|
|
154
156
|
| `coupons-totals` | — | `{ total, by: { [discount_type]: count } }` |
|
|
155
157
|
| `reviews-totals` | — | `{ total, by: { [status]: count } }` |
|
|
@@ -160,7 +162,7 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
|
|
|
160
162
|
|
|
161
163
|
Actions: `status` · `payment-connector-status` · `admin-email-recipients` · `recount-terms` · `recount-coupon-usage` · `recalculate-customer-stats-all` · `prune-webhook-deliveries` · `clear-abandoned-carts` · `regenerate-download-permissions`
|
|
162
164
|
|
|
163
|
-
- **`status`** — `{ template_version, seeded, settings_groups, counts: {
|
|
165
|
+
- **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways, has_default_zone } }` — `counts` is keyed by the **namespaced** entity name, and `checks` is an object, not an array. — mini system-status; also the seeded/health check for install verification.
|
|
164
166
|
- **`payment-connector-status`** — no payload → `{ provider, provider_label, gateway_slug, connected, error?, connector }`. Whether an online payment provider is usable **right now**, answered by the payment utility for whichever provider is wired — so UI derives payment readiness instead of hardcoding a "not set up" notice, and shows "No payment provider connected" rather than a brand. Connectors are service-role only, hence the round trip; any failure reports `connected: false`. A provider connected **after** the function's last deploy reads as `connected: false` until the functions are redeployed — env vars are injected at deploy time ([`references/online-payments.md`](../references/online-payments.md) §2). (`connector` repeats `provider` for callers written against the older shape.)
|
|
165
167
|
- **`admin-email-recipients`** — no payload → `{ recipients: string[], source: "settings" | "admin_users", admin_users: string[] }`. Where admin notifications go **right now**: `recipients` is the configured `emails.admin_recipients`, or the app's admin users when that is empty (the runtime fallback), with `source` saying which. `admin_users` is returned either way, so Settings → Emails can show the fallback as the field's placeholder even while explicit recipients are set. A client can't resolve it itself — listing users needs service role.
|
|
166
168
|
- **`recount-terms`** — repairs category/tag/term `count`.
|
|
@@ -183,4 +185,4 @@ Actions: `status` · `create-link` · `complete-return` · `verify` — the admi
|
|
|
183
185
|
|
|
184
186
|
## commerce/seed-store
|
|
185
187
|
|
|
186
|
-
Not action-routed. Body `{ with_sample_data?: boolean, store_name?: string }`. **`store_name` is required** when the `
|
|
188
|
+
Not action-routed. Body `{ with_sample_data?: boolean, store_name?: string }`. **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently and (if `with_sample_data` and the store has no products) a sample catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. → `{ seeded: { settings_groups, gateways, tax_classes, zones, zone_methods }, sample_data: {...counts} | false, store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" } }`.
|
|
@@ -8,7 +8,7 @@ Read this list before writing any of it. Each item is a hard requirement enforce
|
|
|
8
8
|
|
|
9
9
|
| # | Requirement | Enforced by |
|
|
10
10
|
|---|---|---|
|
|
11
|
-
| 1 | **
|
|
11
|
+
| 1 | **Products with variants: one selector per attribute**, resolved to a `variation_id` before adding to the cart — never a flat list of combinations | `add-item` → `400 variation_required` ([product page](#get-product)) |
|
|
12
12
|
| 2 | **Present shipping options and send a choice** — drive it off the cart's `shipping_status`; a single option is auto-applied, several mean you must ask | `place-order` → `400 shipping_method_required` ([shipping](#shipping-is-not-optional--read-this-before-building-checkout)) |
|
|
13
13
|
| 3 | **Redirect to `payment.checkout_url`** when the customer pays by card | the order stays `pending` otherwise |
|
|
14
14
|
| 4 | **Implement the return page** (`/order-received`, or your own route set in Settings → General → *Payment return path*) and call `commerce/payments` `complete-return` there | without it a paid order is never confirmed, and a wrong path is a 404 |
|
|
@@ -22,8 +22,8 @@ The backend is considerably richer than a minimal "grid → cart → pay" shop.
|
|
|
22
22
|
|
|
23
23
|
| Capability | Already supported | Where |
|
|
24
24
|
|---|---|---|
|
|
25
|
-
| **Product discovery** | full-text `search`, filter by category (incl. descendants), tag, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by
|
|
26
|
-
| **Rich product pages** | galleries,
|
|
25
|
+
| **Product discovery** | full-text `search`, filter by category (incl. descendants), tag, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (the default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
|
|
26
|
+
| **Rich product pages** | galleries, per-variant price/stock/image, categories, tags, **upsells**, **cross-sells** | [`get-product`](#get-product) |
|
|
27
27
|
| **Taxonomy navigation** | category tree, **tag list with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories), [`list-tags`](#list-tags), [`list-attributes`](#list-attributes) |
|
|
28
28
|
| **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by signed-in customers**; plus "my reviews" | [`get-product`](#get-product), [`submit-review`](#submit-review), [`my-reviews`](#commercestorefront-account) |
|
|
29
29
|
| **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`commerce/storefront-cart`](#commercestorefront-cart) |
|
|
@@ -62,10 +62,13 @@ Bootstrap data for a storefront. No payload.
|
|
|
62
62
|
**Response:**
|
|
63
63
|
```json
|
|
64
64
|
{
|
|
65
|
-
"settings": { "
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
65
|
+
"settings": { "store_name": "Acme", "currency": "USD", "currency_position": "left",
|
|
66
|
+
"thousand_sep": ",", "decimal_sep": ".", "num_decimals": 2,
|
|
67
|
+
"weight_unit": "kg", "dimension_unit": "cm",
|
|
68
|
+
"prices_include_tax": false, "display_prices_shop": "excl", "display_prices_cart": "excl",
|
|
69
|
+
"enable_reviews": true, "review_rating_required": true, "hide_out_of_stock": false },
|
|
70
|
+
"payment_gateways": [ { "slug": "offline", "title": "Offline payment", "description": "...", "online": false },
|
|
71
|
+
{ "slug": "stripe", "title": "Credit card", "description": "...", "online": true } ],
|
|
69
72
|
"countries": [ { "code": "US", "name": "United States", "states": [ { "code": "CA", "name": "California" } ] } ],
|
|
70
73
|
"currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
|
|
71
74
|
}
|
|
@@ -73,13 +76,13 @@ Bootstrap data for a storefront. No payload.
|
|
|
73
76
|
`settings` is a safe projection — only display/behavior keys, never admin config.
|
|
74
77
|
|
|
75
78
|
### `list-products`
|
|
76
|
-
**Payload** (all optional): `search`, `category_id` (includes descendants), `tag_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (
|
|
79
|
+
**Payload** (all optional): `search`, `category_id` (includes descendants), `tag_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (`-created_date`|`name`|`price`|`-price`|`popularity`|`rating`, default `-created_date`), `page` (default 1), `per_page` (default 12, max 100).
|
|
77
80
|
|
|
78
81
|
Only `status: "publish"` products are returned. Visibility: passing `search` uses **search context** (hides `catalog_visibility: hidden|catalog`); no `search` uses **browse context** (hides `hidden|search`). `inventory.hide_out_of_stock` (or `in_stock_only`) drops out-of-stock products.
|
|
79
82
|
|
|
80
83
|
**Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
|
|
81
84
|
|
|
82
|
-
Each row is the product record (minus paywalled fields) **plus a resolved `tags` array** (`[{ id, name
|
|
85
|
+
Each row is the product record (minus paywalled fields) **plus a resolved `tags` array** (`[{ id, name }]`), so cards can show tags without a second call. `categories` are **not** resolved on rows — `category_ids` only.
|
|
83
86
|
|
|
84
87
|
> **What to render, and what a row can't show:** [`../references/product-render.md`](../references/product-render.md) — field-availability table for list vs. product page, tags in both views, variable-product pricing on cards, and how to add a `get-product`-only field to the listing call instead of fetching per card.
|
|
85
88
|
|
|
@@ -92,7 +95,7 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
|
|
|
92
95
|
```json
|
|
93
96
|
{
|
|
94
97
|
"product": { Product },
|
|
95
|
-
"variations": [ ProductVariation... ], // publishable only; []
|
|
98
|
+
"variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
|
|
96
99
|
"categories": [ ProductCategory... ],
|
|
97
100
|
"tags": [ ProductTag... ],
|
|
98
101
|
"reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
|
|
@@ -100,12 +103,11 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
|
|
|
100
103
|
"average_rating": 4.5, "rating_count": 12 },
|
|
101
104
|
"upsells": [ { "id", "name", "slug", "price", "on_sale", "image", "stock_status" } ],
|
|
102
105
|
"cross_sells": [ ...summaries ],
|
|
103
|
-
"grouped_products": [ ...summaries ]
|
|
104
106
|
}
|
|
105
107
|
```
|
|
106
108
|
**Errors:** `404 not_found` (missing / not published / hidden).
|
|
107
109
|
|
|
108
|
-
> **Rendering this response as a product page — the rule, not just a link.** `variations[]` is **not** a list of choices to show. Build **one control per `product.attributes[]` entry
|
|
110
|
+
> **Rendering this response as a product page — the rule, not just a link.** `variations[]` is **not** a list of choices to show. Build **one control per `product.attributes[]` entry** (Size, Color, …) — every attribute is an axis — and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variant prices come from `variations[]`, never from `product.price` (the backend rolls stock up to the parent, not price). The shipped helper does all of it:
|
|
109
111
|
> ```js
|
|
110
112
|
> import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
111
113
|
> const [selection, setSelection] = useState(() => defaultSelection(product, variations));
|
|
@@ -125,21 +127,21 @@ No payload. Returns a nested tree: `{ "categories": [ { ...category, "children":
|
|
|
125
127
|
### `list-tags`
|
|
126
128
|
**Payload** (optional): `{ with_products_only?: boolean }` (default `true`).
|
|
127
129
|
|
|
128
|
-
Returns `{ "tags": [ { id, name,
|
|
130
|
+
Returns `{ "tags": [ { id, name, count } ] }` sorted by name. By default it omits tags no published product carries, so a tag nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
|
|
129
131
|
|
|
130
132
|
`count` is tallied from the products this API would actually list — published, browse-visible, and honoring `inventory.hide_out_of_stock` — **not** from `ProductTag.count`, which also counts drafts and hidden products and drifts until `commerce/admin-tools` `recount-terms` runs. So `Gift (12)` and the tag's listing agree.
|
|
131
133
|
|
|
132
134
|
This is the **only** way a storefront can enumerate tags — `commerce.ProductTag` is admin-only RLS — and it's what makes `list-products` `tag_id` usable, since that filter needs an id. Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Show both: see [*Tags*](../references/product-render.md#2-tags--render-them-in-both-views) for where they belong in each view.
|
|
133
135
|
|
|
134
136
|
### `list-attributes`
|
|
135
|
-
No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...
|
|
137
|
+
No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — the whole attribute record (`id, name, code, order`) plus its values (`id, attribute_id, name, order, count`), attributes sorted by `order` and each attribute's values by their own `order` — for building filter UIs. Filter with `list-products` `attribute_id` (the id, or the attribute **name**) + `attribute_term` (the value name); `code` is the stable key to put in a URL.
|
|
136
138
|
|
|
137
139
|
### `submit-review` — **auth**
|
|
138
140
|
**Payload:** `{ product_id, reviewer?, review, rating }`.
|
|
139
141
|
|
|
140
142
|
**Requires a signed-in customer.** The reviewer's email is taken from the session, never from the payload — a `reviewer_email` field is ignored. `reviewer` is the display name only, and defaults to the account's `full_name`. Build the review form behind a login prompt: anonymous callers get `401 login_required`.
|
|
141
143
|
|
|
142
|
-
Requires `products.enable_reviews`
|
|
144
|
+
Requires `products.enable_reviews` — reviews are a store-wide switch, not per product. Rating required when `review_rating_required`. If `only_verified_reviews`, the signed-in customer must have a `processing`/`completed` order containing the product. `verified` is derived from their own order history. Status is `hold` unless `auto_approve_reviews`.
|
|
143
145
|
|
|
144
146
|
**Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
|
|
145
147
|
**Errors:** `401 login_required`, `403 reviews_disabled|verified_only|forbidden`, `404 not_found`, `400 review_incomplete|rating_required|invalid_rating`.
|
|
@@ -184,10 +186,10 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
|
|
|
184
186
|
| `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
|
|
185
187
|
| `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
|
|
186
188
|
| `totals` | `{ cart_token }` | Alias of `get`. |
|
|
187
|
-
| `add-item` | `{ cart_token, product_id, variation_id?, quantity?, attributes? }` |
|
|
189
|
+
| `add-item` | `{ cart_token, product_id, variation_id?, quantity?, attributes? }` | A product carrying attributes requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
|
|
188
190
|
| `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
|
|
189
191
|
| `remove-item` | `{ cart_token, item_key }` | |
|
|
190
|
-
| `apply-coupon` | `{ cart_token, code }` | Full validation. `400
|
|
192
|
+
| `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
|
|
191
193
|
| `remove-coupon` | `{ cart_token, code }` | |
|
|
192
194
|
| `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched zone's `available_shipping_methods` and a `shipping_status`. |
|
|
193
195
|
| `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a `commerce.ShippingZoneMethod` id from `available_shipping_methods`. `400 invalid_shipping_method` (the error body carries `available_shipping_methods`). |
|
|
@@ -219,15 +221,15 @@ Rules the cart enforces on every price, so you get them for free:
|
|
|
219
221
|
|
|
220
222
|
Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and the checkout will only honour what's actually configured. Don't write such a line into a storefront unless a matching rule exists — either configure it (with the operator's agreement) or leave the copy out.
|
|
221
223
|
|
|
222
|
-
`commerce.ShippingZone` and `commerce.ShippingZoneMethod` are
|
|
224
|
+
**Zones are admin-only data.** `commerce.ShippingZone` and `commerce.ShippingZoneMethod` are `read: {role: "admin"}` like every entity, so a visitor cannot list them — do not try. Two honest sources:
|
|
225
|
+
|
|
226
|
+
1. **The cart itself**, which is the live answer for a real address: after `set-shipping-address`, `available_shipping_methods` already reflects every rule the checkout will honour, free shipping included. Prefer this.
|
|
227
|
+
2. **A projection you add**, if you want a threshold banner *before* an address exists: widen `get-store-info` (or add an action) to return the `free_shipping` rules, then normalize them with [`src/commerce/utils/shipping-promos.js`](../../../src/commerce/utils/shipping-promos.js):
|
|
223
228
|
|
|
224
229
|
```js
|
|
225
230
|
import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
|
|
226
231
|
|
|
227
|
-
|
|
228
|
-
base44.entities["commerce.ShippingZone"].list("order", 100),
|
|
229
|
-
base44.entities["commerce.ShippingZoneMethod"].list(undefined, 200),
|
|
230
|
-
]);
|
|
232
|
+
// zones/methods come from your own storefront action — NOT from a client entity read
|
|
231
233
|
const threshold = freeShippingThreshold(freeShippingRules(zones, methods));
|
|
232
234
|
// null → the store has no free-shipping rule: show no banner, no progress bar.
|
|
233
235
|
const progress = freeShippingProgress(threshold, cart.totals.subtotal); // { threshold, qualifies, remaining }
|
|
@@ -242,7 +244,7 @@ A free-shipping rule is a `free_shipping` zone method whose `settings.requires`
|
|
|
242
244
|
### `place-order`
|
|
243
245
|
Converts a cart into an order. **Payload:**
|
|
244
246
|
```json
|
|
245
|
-
{ "cart_token": "uuid", "payment_method": "
|
|
247
|
+
{ "cart_token": "uuid", "payment_method": "offline",
|
|
246
248
|
"billing": { "first_name", "last_name", "address_1", "address_2?", "city", "state?", "postcode?", "country", "email", "phone?" },
|
|
247
249
|
"shipping": { ...address without email },
|
|
248
250
|
"chosen_shipping_method?": "<zoneMethodId>",
|
|
@@ -269,9 +271,9 @@ Converts a cart into an order. **Payload:**
|
|
|
269
271
|
|
|
270
272
|
The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
|
|
271
273
|
|
|
272
|
-
> **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Card payments are **implemented**: choosing the online gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, session_id }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `verify`. Never treat a return URL as proof of payment. The
|
|
274
|
+
> **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Card payments are **implemented**: choosing the online gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, session_id }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `verify`. Never treat a return URL as proof of payment. The `offline` gateway settles outside the store and returns `payment_instructions` instead. An online gateway is only listed while a payment provider is connected, so anything `get-store-info` offers is payable. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
|
|
273
275
|
|
|
274
|
-
**Steps:** releases expired holds → revalidates stock & coupons → computes authoritative totals → finds-or-creates the Customer by billing email → creates a `pending` order (with `order_key`, `hold_expires_at`) → reduces stock, fires `new_order` email + `order.created` webhook → marks cart `converted` → routes by gateway: **
|
|
276
|
+
**Steps:** releases expired holds → revalidates stock & coupons → computes authoritative totals → finds-or-creates the Customer by billing email → creates a `pending` order (with `order_key`, `hold_expires_at`) → reduces stock, fires `new_order` email + `order.created` webhook → marks cart `converted` → routes by gateway: **offline → on-hold** (with `payment_instructions`), **stripe → stays pending** until the hosted page is paid, **custom → pending_external**.
|
|
275
277
|
|
|
276
278
|
> **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with. The **`commerce.Customer`** record behind it — the one a storefront prefills from — is refreshed only when the caller is signed in *as* the billing email. A guest checkout quoting an existing customer's address still attaches the order to them (so it shows up in their `my-orders`), but cannot change their saved name or addresses. Don't build an "edit my details at checkout" flow for guests; use `update-my-addresses` behind a login instead.
|
|
277
279
|
|
|
@@ -279,10 +281,12 @@ Converts a cart into an order. **Payload:**
|
|
|
279
281
|
```json
|
|
280
282
|
{
|
|
281
283
|
"order_id": "...", "order_number": 1001, "order_key": "order_...",
|
|
282
|
-
"status": "
|
|
283
|
-
"payment_method": "
|
|
284
|
-
"payment_instructions": { "type": "
|
|
285
|
-
"payment": null, //
|
|
284
|
+
"status": "on-hold", "currency": "USD",
|
|
285
|
+
"payment_method": "offline", "payment_method_title": "Offline payment",
|
|
286
|
+
"payment_instructions": { "type": "offline", "description": "...", "account_details": [...] },
|
|
287
|
+
"payment": null, // offline. For the online gateway:
|
|
288
|
+
// { "status": "requires_payment", "provider", "checkout_url", "session_id" }
|
|
289
|
+
// For a custom gateway: { "status": "pending_external" }
|
|
286
290
|
"notices": [], // e.g. ["account_creation_requires_login"]
|
|
287
291
|
"totals": { "subtotal", "discount_total", "shipping_total", "shipping_tax", "cart_tax", "total_tax", "total" },
|
|
288
292
|
"order": { ...customer-safe order (internal flags/ip stripped) }
|
|
@@ -350,9 +354,9 @@ let cart = await inv("commerce/storefront-cart", { action: "create",
|
|
|
350
354
|
items: [{ product_id: products[0].id, quantity: 1 }] });
|
|
351
355
|
const token = cart.cart_token;
|
|
352
356
|
|
|
353
|
-
// 3. (
|
|
357
|
+
// 3. (product with variants) fetch options, then add the chosen variation
|
|
354
358
|
const detail = await inv("commerce/storefront-catalog", { action: "get-product", id: products[0].id });
|
|
355
|
-
if (detail.product.
|
|
359
|
+
if ((detail.product.attributes ?? []).length) {
|
|
356
360
|
cart = await inv("commerce/storefront-cart", { action: "add-item",
|
|
357
361
|
cart_token: token, product_id: detail.product.id, variation_id: detail.variations[0].id });
|
|
358
362
|
}
|
|
@@ -372,9 +376,9 @@ if (cart.shipping_status === "choice_required") {
|
|
|
372
376
|
throw new Error("We don't ship to this address");
|
|
373
377
|
}
|
|
374
378
|
|
|
375
|
-
// 6. Place the order (
|
|
379
|
+
// 6. Place the order (offline → on-hold; no money has arrived yet)
|
|
376
380
|
const order = await inv("commerce/storefront-checkout", { action: "place-order",
|
|
377
|
-
cart_token: token, payment_method: "
|
|
381
|
+
cart_token: token, payment_method: "offline",
|
|
378
382
|
billing: { first_name: "Ada", last_name: "Lovelace", address_1: "1 St",
|
|
379
383
|
city: "Los Angeles", state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
|
|
380
384
|
|
|
@@ -398,8 +402,8 @@ await inv("commerce/storefront-account", { action: "update-my-addresses",
|
|
|
398
402
|
state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
|
|
399
403
|
|
|
400
404
|
const order = await inv("commerce/storefront-checkout", { action: "place-order",
|
|
401
|
-
cart_token: cart.cart_token, payment_method: "
|
|
402
|
-
//
|
|
405
|
+
cart_token: cart.cart_token, payment_method: "offline", billing: { /* ... */ } });
|
|
406
|
+
// offline → on-hold; show order.payment_instructions.account_details
|
|
403
407
|
|
|
404
408
|
// Order history, downloads, reviews (all auth, no order_key needed)
|
|
405
409
|
const { orders } = await inv("commerce/storefront-account", { action: "my-orders", per_page: 10 });
|