@base44/app-plugin-commerce 0.1.4 → 0.1.6

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 CHANGED
@@ -7,7 +7,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
7
7
  ## What's included
8
8
 
9
9
  - **24 entities** — Products (a product sells variants when it carries attributes; no type field), variations, categories, tags, attributes + values, reviews, orders (embedded line/shipping/tax/fee/coupon lines), order notes, refunds, coupons, customers, tax classes/rates, shipping zones/methods, payment gateways, store settings, webhooks + deliveries, carts, download permissions, email log.
10
- - **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store`.
10
+ - **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store` — one call seeds the business defaults **and the whole catalog** (products with attributes in, variants/categories/taxonomy created internally).
11
11
  - **Online card payments, implemented** — hosted payment page, payment links for unpaid orders, two idempotent confirmation paths (customer return + signed webhook) and refunds through the provider. Wired to **Stripe** out of the box behind a provider-neutral payment utility, so the store takes cards as soon as the connector is connected — no charge flow to write — and moving to another provider means implementing one adapter. See [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md).
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, payments utility + Stripe adapter, plus static country/currency/continent data.
13
13
  - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
@@ -38,7 +38,7 @@ base44-commerce-template/
38
38
  │ │ know the store natively
39
39
  │ ├── SKILL.md the map: short overview + links to everything below
40
40
  │ ├── installation-guidelines.md installing into an app (scripted or manual)
41
- │ ├── post-installation.md embedding the admin pages + AGENTS.md registration
41
+ │ ├── post-installation.md embedding the admin pages, seeding, storefront quick start
42
42
  │ ├── references/ per-topic guides (product rendering, product page & variants,
43
43
  │ │ Stripe, scheduled work, emails, webhooks, media & downloads,
44
44
  │ │ limits, security)
@@ -86,7 +86,7 @@ From your existing Base44 app:
86
86
  <Route path="/store-admin/*" element={<AdminApp />} />
87
87
  ```
88
88
  6. **Grant yourself the `admin` role** (Base44 dashboard → users, or `users.inviteUser(email, "admin")`). The admin UI refuses non-admins.
89
- 7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, gateways, tax classes and a fallback shipping zone, plus a sample catalog when `with_sample_data: true` and the store is empty. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. If an agent is installing this, it should **ask which data the user wants** first — a generated catalog for their actual business, the generic demo data, or nothing: see [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.
89
+ 7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, gateways, tax classes and a fallback shipping zone, plus the catalog: pass `products` (whole products with attributes — variants, categories and taxonomy are created internally) or `with_sample_data: true` for the generic demo. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. Payload reference and a worked example: [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.
90
90
 
91
91
  ## Quick start (Base44 MCP / hosted apps)
92
92
 
@@ -94,7 +94,7 @@ If you build on Base44's hosted platform, use the Base44 agent/MCP to write the
94
94
 
95
95
  1. Copy this whole repo into the target app at `examples/commerce/` (e.g. download + extract a tarball with `run_command`), then run `node examples/commerce/scripts/install.js` via `run_command` — or use `write_file` to copy every file under `base44/` and `src/commerce/` individually (use `list_directory`/`read_file` to adapt to the app's actual layout — e.g. the `@/api/base44Client` path and your router file).
96
96
  2. Wait for the app to build (`get_app_status`), then confirm entities exist (`list_entity_schemas`).
97
- 3. Grant your user the `admin` role, then settle the store's data with the user — generate a real starter catalog via `commerce/seed-store` + `commerce/admin-products`, seed the demo catalog, or leave it to the admin's first-run **Initialize store defaults** screen ([`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2).
97
+ 3. Grant your user the `admin` role, then seed the store's data — one `commerce/seed-store` call takes the whole catalog via `products`, or `with_sample_data: true` for the demo catalog; leave both out for defaults only, or skip the call for the admin's first-run **Initialize store defaults** screen ([`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2).
98
98
 
99
99
  ## What's NOT included
100
100
 
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "StoreAdmin",
12
12
  "description": "Store administration copilot for the commerce template: manage products, orders, refunds, coupons, customers, reviews, reports and maintenance.",
13
- "instructions": "You are StoreAdmin, the store administration copilot for this shop's back office. You help store operators inspect and manage the store: products, orders, refunds, coupons, customers, reviews, webhooks, reports, and maintenance.\n\n## How you access the store\nEvery tool takes a JSON body of the form {\"action\": \"<action>\", ...payload} (exception: commerce/seed-store takes {store_name, with_sample_data?} with no action key — store_name is REQUIRED on a first seed) and responds {success, data} or {success:false, error, code}. Search/list actions return {rows, has_next} using limit+skip pagination (there are no total counts). Use search actions for free-text lookups (product name, customer email, coupon code, order number).\n\nStore configuration (settings, tax rates, shipping zones, payment gateways, webhook definitions) is not editable through your tools — see \"Sending the operator to a screen\" below.\n\n## Sending the operator to a screen\nSome configuration is only editable in the admin UI. When one of those is asked for, say plainly that you cannot change it from chat, name the screen, and give a link the operator can click — never just \"do it manually\", and never imply you tried and failed. Do not blame permissions or a security error: the reason is simply that the UI is the only place that configuration is edited.\n\nLinks use the `admin:` scheme with a path from the table below — `[Settings → Tax](admin:settings/tax)`. The chat resolves that to wherever the admin is mounted, so never write `/store-admin/...` yourself.\n\n| Ask | Screen | Link |\n|---|---|---|\n| Currency and price formatting, payment return path | Settings → General | admin:settings/general |\n| Catalog defaults, review settings | Settings → Products | admin:settings/products |\n| Stock thresholds, hold minutes, inventory recipient | Settings → Inventory | admin:settings/inventory |\n| Tax classes, tax rates, price display | Settings → Tax | admin:settings/tax |\n| Shipping zones, methods, shipping classes | Settings → Shipping | admin:settings/shipping |\n| Enabling a gateway, offline bank details | Settings → Payments | admin:settings/payments |\n| Store name (subjects + sender), admin notification recipients, per-email overrides | Settings → Emails | admin:settings/emails |\n| Creating or deleting a webhook (you CAN test and redeliver) | Settings → Webhooks | admin:settings/webhooks |\n\nExample: \"Tax rates aren't something I can change from here — they live in the store's tax settings. Open [Settings → Tax](admin:settings/tax) to add the rate, then tell me and I'll re-check the order's totals.\"\n\nCatalog taxonomy is the opposite: you CAN create categories, tags, attributes and attribute values yourself with commerce/admin-products save-term. Never send the operator to a screen to create one — link to admin:products/categories if they want to review or reorder categories by hand. Tags and attributes have no screen of their own: attributes are edited in the Attributes section of a product's Price & Inventory tab, tags in the Tags card of the product sidebar — so link to the product (admin:products) instead.\n\n## Product variants — be careful\nA product that carries attributes is sold through its variants (there is no product type), and every variant can differ in attributes (e.g. size/color), SKU, price and stock.\n- NEVER pick a variation automatically. When an order line, stock change, or download grant involves a product that carries attributes, first fetch its variations with commerce/storefront-catalog {\"action\":\"get-product\",\"id\":...} (returns {product, variations}), present them in a table (attributes, SKU, price, stock status), and ask the operator which variation to use — then include that variation_id in the item spec.\n- If the operator already named an exact variation (by SKU or full attribute combination), match it against the fetched variations and confirm the match in your reply; if the description is partial or matches more than one variation, ask.\n- Order item specs for commerce/admin-orders create/update are {product_id, variation_id?, quantity, price_override?} — variation_id is REQUIRED for a product with attributes.\n- The same applies to commerce/admin-products set-stock (pass variation_id to change a variation's stock, not the parent's).\n\n## Behavior\n- Be concise and operational. Confirm before destructive or irreversible operations (delete, refund, bulk-status, prune, clear-abandoned-carts) by restating what will happen and asking the user to confirm — unless the user's message already explicitly confirms it.\n- When showing lists or reports, format them as GitHub-flavored markdown tables (| col | col | with a |---| separator row). Keep tables ≤ 8 columns; prefer the most decision-relevant fields (name/number, status, total, date). Format money with the store currency.\n- After a mutation, report exactly what changed (ids, statuses, totals) and surface any error/code verbatim.\n- If a request is ambiguous (which order? which product?), search first and present the candidates in a table, then ask.\n- For store health questions, start with commerce/admin-tools {\"action\":\"status\"} and commerce/admin-reports {\"action\":\"summary\"}.\n- Payments: for an unpaid order paid online, commerce/payments create-link {order_id} gives a payment page link to send the customer, and verify {order_id} re-checks whether the money arrived. If no payment provider is connected, say \"no payment provider is connected\" and that connecting one enables card payments — don't name or troubleshoot a specific provider. Never invent a payment link or claim an order is paid without verifying.\n- You act with store-operator privileges; do not attempt to weaken or bypass access controls, and never expose secrets (webhook secrets, tokens).",
13
+ "instructions": "You are StoreAdmin, the store administration copilot for this shop's back office. You help store operators inspect and manage the store: products, orders, refunds, coupons, customers, reviews, webhooks, reports, and maintenance.\n\n## How you access the store\nEvery tool takes a JSON body of the form {\"action\": \"<action>\", ...payload} (exception: commerce/seed-store takes {store_name, currency?, with_sample_data?, products?, coupons?, tax_rates?} with no action key — store_name is REQUIRED on a first seed, and products can bootstrap a whole catalog in one call, variants included; see skills/commerce/docs/api-admin.md) and responds {success, data} or {success:false, error, code}. Search/list actions return {rows, has_next} using limit+skip pagination (there are no total counts). Use search actions for free-text lookups (product name, customer email, coupon code, order number).\n\nStore configuration (settings, tax rates, shipping zones, payment gateways, webhook definitions) is not editable through your tools — see \"Sending the operator to a screen\" below.\n\n## Sending the operator to a screen\nSome configuration is only editable in the admin UI. When one of those is asked for, say plainly that you cannot change it from chat, name the screen, and give a link the operator can click — never just \"do it manually\", and never imply you tried and failed. Do not blame permissions or a security error: the reason is simply that the UI is the only place that configuration is edited.\n\nLinks use the `admin:` scheme with a path from the table below — `[Settings → Tax](admin:settings/tax)`. The chat resolves that to wherever the admin is mounted, so never write `/store-admin/...` yourself.\n\n| Ask | Screen | Link |\n|---|---|---|\n| Currency and price formatting, payment return path | Settings → General | admin:settings/general |\n| Catalog defaults, review settings | Settings → Products | admin:settings/products |\n| Stock thresholds, hold minutes, inventory recipient | Settings → Inventory | admin:settings/inventory |\n| Tax classes, tax rates, price display | Settings → Tax | admin:settings/tax |\n| Shipping zones, methods, shipping classes | Settings → Shipping | admin:settings/shipping |\n| Enabling a gateway, offline bank details | Settings → Payments | admin:settings/payments |\n| Store name (subjects + sender), admin notification recipients, per-email overrides | Settings → Emails | admin:settings/emails |\n| Creating or deleting a webhook (you CAN test and redeliver) | Settings → Webhooks | admin:settings/webhooks |\n\nExample: \"Tax rates aren't something I can change from here — they live in the store's tax settings. Open [Settings → Tax](admin:settings/tax) to add the rate, then tell me and I'll re-check the order's totals.\"\n\nCatalog taxonomy is the opposite: you CAN create categories, tags, attributes and attribute values yourself with commerce/admin-products save-term. Never send the operator to a screen to create one — link to admin:products/categories if they want to review or reorder categories by hand. Tags and attributes have no screen of their own: attributes are edited in the Attributes section of a product's Price & Inventory tab, tags in the Tags card of the product sidebar — so link to the product (admin:products) instead.\n\n## Product variants — be careful\nA product that carries attributes is sold through its variants (there is no product type), and every variant can differ in attributes (e.g. size/color), SKU, price and stock.\n- NEVER pick a variation automatically. When an order line, stock change, or download grant involves a product that carries attributes, first fetch its variations with commerce/storefront-catalog {\"action\":\"get-product\",\"id\":...} (returns {product, variations}), present them in a table (attributes, SKU, price, stock status), and ask the operator which variation to use — then include that variation_id in the item spec.\n- If the operator already named an exact variation (by SKU or full attribute combination), match it against the fetched variations and confirm the match in your reply; if the description is partial or matches more than one variation, ask.\n- Order item specs for commerce/admin-orders create/update are {product_id, variation_id?, quantity, price_override?} — variation_id is REQUIRED for a product with attributes.\n- The same applies to commerce/admin-products set-stock (pass variation_id to change a variation's stock, not the parent's).\n\n## Behavior\n- Be concise and operational. Confirm before destructive or irreversible operations (delete, refund, bulk-status, prune, clear-abandoned-carts) by restating what will happen and asking the user to confirm — unless the user's message already explicitly confirms it.\n- When showing lists or reports, format them as GitHub-flavored markdown tables (| col | col | with a |---| separator row). Keep tables ≤ 8 columns; prefer the most decision-relevant fields (name/number, status, total, date). Format money with the store currency.\n- After a mutation, report exactly what changed (ids, statuses, totals) and surface any error/code verbatim.\n- If a request is ambiguous (which order? which product?), search first and present the candidates in a table, then ask.\n- For store health questions, start with commerce/admin-tools {\"action\":\"status\"} and commerce/admin-reports {\"action\":\"summary\"}.\n- Payments: for an unpaid order paid online, commerce/payments create-link {order_id} gives a payment page link to send the customer, and verify {order_id} re-checks whether the money arrived. If no payment provider is connected, say \"no payment provider is connected\" and that connecting one enables card payments — don't name or troubleshoot a specific provider. Never invent a payment link or claim an order is paid without verifying.\n- You act with store-operator privileges; do not attempt to weaken or bypass access controls, and never expose secrets (webhook secrets, tokens).",
14
14
  "tool_configs": [
15
15
  {
16
16
  "function_name": "commerce/admin-products",
@@ -14,7 +14,17 @@ import { isVariable } from "../../../shared/commerce/products.ts";
14
14
  import { sendStockEmail } from "../../../shared/commerce/emails.ts";
15
15
  import { dispatch } from "../../../shared/commerce/webhooks.ts";
16
16
  import { pageSlice, scanAll, textMatch } from "../../../shared/commerce/scan.ts";
17
- import { round2 } from "../../../shared/commerce/money.ts";
17
+ import {
18
+ adjustTermCounts,
19
+ assertUniqueSku,
20
+ bumpCount,
21
+ derivePricing,
22
+ deriveStock,
23
+ ensureUniqueCode,
24
+ ensureUniqueSlug,
25
+ rollUpParent,
26
+ slugify,
27
+ } from "../../../shared/commerce/catalog.ts";
18
28
 
19
29
  const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
20
30
  const fail = (status: number, error: string, code?: string) =>
@@ -56,87 +66,6 @@ Deno.serve(async (req) => {
56
66
  }
57
67
  });
58
68
 
59
- // ── derivations ──────────────────────────────────────────────────────────────
60
-
61
- function slugify(name: string): string {
62
- return String(name || "")
63
- .toLowerCase()
64
- .trim()
65
- .replace(/[^a-z0-9]+/g, "-")
66
- .replace(/^-+|-+$/g, "") || "product";
67
- }
68
-
69
- /** price/on_sale from the sale window. Mutates rec. */
70
- function derivePricing(rec: any): void {
71
- const now = Date.now();
72
- const from = rec.date_on_sale_from ? new Date(rec.date_on_sale_from).getTime() : -Infinity;
73
- const to = rec.date_on_sale_to ? new Date(rec.date_on_sale_to).getTime() : Infinity;
74
- const saleActive = rec.sale_price != null && rec.sale_price !== "" && now >= from && now <= to;
75
- rec.on_sale = !!saleActive;
76
- const effective = saleActive ? rec.sale_price : rec.regular_price;
77
- if (effective != null && effective !== "") rec.price = round2(Number(effective));
78
- }
79
-
80
- /** stock_status derivation when stock is managed. Mutates rec. */
81
- function deriveStock(rec: any, outThreshold: number, managed: boolean): void {
82
- if (managed) {
83
- rec.stock_status = deriveStockStatus(
84
- Number(rec.stock_quantity ?? 0),
85
- rec.backorders ?? "no",
86
- outThreshold,
87
- );
88
- } else if (!rec.stock_status) {
89
- rec.stock_status = "instock";
90
- }
91
- }
92
-
93
- // ── uniqueness ───────────────────────────────────────────────────────────────
94
-
95
- async function ensureUniqueSlug(
96
- sr: any,
97
- slug: string,
98
- selfId?: string,
99
- entity = "commerce.Product",
100
- scope?: Record<string, any>,
101
- ): Promise<string> {
102
- let candidate = slug;
103
- for (let i = 2; i < 100; i++) {
104
- const hits = (await sr.entities[entity].filter({ ...(scope ?? {}), slug: candidate }, undefined, 2)) ?? [];
105
- if (!hits.some((p: any) => p.id !== selfId)) return candidate;
106
- candidate = `${slug}-${i}`;
107
- }
108
- return `${slug}-${crypto.randomUUID().slice(0, 6)}`;
109
- }
110
-
111
- /** SKU must be unique across products AND variations. Throws duplicate_sku. */
112
- async function assertUniqueSku(sr: any, sku: string, opts: { productId?: string; variationId?: string }): Promise<void> {
113
- if (!sku) return;
114
- const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 2)) ?? [];
115
- if (prods.some((p: any) => p.id !== opts.productId)) {
116
- throw new HttpError(409, `SKU "${sku}" is already in use by another product.`, "duplicate_sku");
117
- }
118
- const vars = (await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 2)) ?? [];
119
- if (vars.some((v: any) => v.id !== opts.variationId)) {
120
- throw new HttpError(409, `SKU "${sku}" is already in use by a product variation.`, "duplicate_sku");
121
- }
122
- }
123
-
124
- // ── taxonomy counts ──────────────────────────────────────────────────────────
125
-
126
- async function adjustTermCounts(sr: any, entity: string, prevIds: string[], nextIds: string[]): Promise<void> {
127
- const added = nextIds.filter((id) => !prevIds.includes(id));
128
- const removed = prevIds.filter((id) => !nextIds.includes(id));
129
- for (const id of added) await bumpCount(sr, entity, id, +1);
130
- for (const id of removed) await bumpCount(sr, entity, id, -1);
131
- }
132
-
133
- async function bumpCount(sr: any, entity: string, id: string, delta: number): Promise<void> {
134
- try {
135
- const rec = await sr.entities[entity].get(id);
136
- if (rec) await sr.entities[entity].update(id, { count: Math.max(0, (rec.count ?? 0) + delta) });
137
- } catch { /* stale reference — recount-terms repairs */ }
138
- }
139
-
140
69
  // ── save ─────────────────────────────────────────────────────────────────────
141
70
 
142
71
  async function save(sr: any, payload: any): Promise<any> {
@@ -214,44 +143,6 @@ async function diffVariations(sr: any, parent: any, incoming: any[], outThreshol
214
143
  return out;
215
144
  }
216
145
 
217
- /**
218
- * Roll a variant parent's stock and price up from its variations.
219
- *
220
- * Price matters beyond display: `list-products` sorts and filters on
221
- * `product.price`, so a parent left empty sorts as free and drops out of every
222
- * price filter. Deriving it removes a number nobody could keep in sync by hand —
223
- * the parent is simply the cheapest thing a customer can actually buy.
224
- */
225
- async function rollUpParent(sr: any, parent: any, variations: any[]): Promise<void> {
226
- if (!isVariable(parent)) return;
227
- const live = variations.filter((v) => (v.status ?? "publish") === "publish");
228
- const patch: Record<string, any> = {};
229
-
230
- // manage_stock on the parent means the merchant tracks one pooled quantity,
231
- // and with it off an explicit stock_status is their own choice to keep.
232
- if (!parent.manage_stock) {
233
- const status = live.length && live.every((v) => v.stock_status === "outofstock")
234
- ? "outofstock"
235
- : "instock";
236
- if (status !== parent.stock_status) patch.stock_status = status;
237
- }
238
-
239
- const cheapest = (key: string): number | null => {
240
- const values = live.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
241
- return values.length ? round2(Math.min(...values)) : null;
242
- };
243
- const regular = cheapest("regular_price");
244
- const price = cheapest("price") ?? regular;
245
- const onSale = live.some((v) => !!v.on_sale);
246
- if (regular !== (parent.regular_price ?? null)) patch.regular_price = regular;
247
- if (price !== (parent.price ?? null)) patch.price = price;
248
- if (onSale !== !!parent.on_sale) patch.on_sale = onSale;
249
-
250
- if (!Object.keys(patch).length) return;
251
- await sr.entities["commerce.Product"].update(parent.id, patch);
252
- Object.assign(parent, patch);
253
- }
254
-
255
146
  // ── delete / batch / duplicate ───────────────────────────────────────────────
256
147
 
257
148
  async function remove(sr: any, id: string): Promise<any> {
@@ -474,17 +365,6 @@ async function saveTerm(sr: any, payload: any): Promise<any> {
474
365
  return await sr.entities[entity].create(withCount);
475
366
  }
476
367
 
477
- /** `code` is what a storefront filter URL carries, so it has to be unique. */
478
- async function ensureUniqueCode(sr: any, code: string, selfId?: string): Promise<string> {
479
- let candidate = code;
480
- for (let i = 2; i < 100; i++) {
481
- const hits = (await sr.entities["commerce.ProductAttribute"].filter({ code: candidate }, undefined, 2)) ?? [];
482
- if (!hits.some((a: any) => a.id !== selfId)) return candidate;
483
- candidate = `${code}-${i}`;
484
- }
485
- return `${code}-${crypto.randomUUID().slice(0, 6)}`;
486
- }
487
-
488
368
  /** Rewrite a renamed value across every product and variant that used it. */
489
369
  async function renameAttributeValue(sr: any, attributeId: string, from: string, to: string): Promise<void> {
490
370
  const products = await scanAll(sr.entities["commerce.Product"]);
@@ -1,22 +1,29 @@
1
1
  /**
2
2
  * commerce/seed-store — idempotent store initialization.
3
3
  *
4
- * Flow: (1) canary schema validation (probe create+delete per entity; abort
5
- * with 422 schema_incompatible and write nothing on any failure), (2) seed
6
- * required defaults (settings groups, gateways, tax classes, fallback zone),
7
- * (3) optional sample catalog (10 products with images and
8
- * descriptions, three of which carry attributes and ship with variants) — only
9
- * when with_sample_data=true and the store has zero products, with best-effort
10
- * rollback on mid-failure.
4
+ * Flow: (1) validate any catalog payload (pure — 400 invalid_payload with
5
+ * per-field errors before anything runs), (2) canary schema validation (probe
6
+ * create+delete per entity; abort with 422 schema_incompatible and write
7
+ * nothing on any failure), (3) seed required defaults (settings groups,
8
+ * gateways, tax classes, fallback zone), (4) the catalog: either the caller's
9
+ * `products`/`coupons`/`tax_rates` (taxonomy get-or-created by name,
10
+ * variations generated from `attributes` when not given, per-product skip when
11
+ * the sku/slug already exists) or the generic demo catalog
12
+ * (with_sample_data=true, only when the store has zero products) — with
13
+ * best-effort rollback on mid-failure. See seed-catalog.ts for the pipeline.
11
14
  *
12
- * Body: { with_sample_data?: boolean, store_name?: string }
15
+ * Body: { store_name?, currency?, with_sample_data?, products?, coupons?, tax_rates? }
16
+ * — with_sample_data cannot be combined with the catalog keys.
13
17
  *
14
18
  * `store_name` is required on a first seed: a function's env is only
15
19
  * BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
20
+ * `currency` is an ISO code; passing it explicitly always wins — it sets
21
+ * general.currency (and the currency's standard num_decimals) whether the
22
+ * group is being created or already exists.
16
23
  */
17
24
  import { createClientFromRequest } from "npm:@base44/sdk";
18
25
  import { HttpError, requireAdmin } from "../../../shared/commerce/auth.ts";
19
- import { round2 } from "../../../shared/commerce/money.ts";
26
+ import { CURRENCIES } from "../../../shared/commerce/data/currencies.ts";
20
27
  import {
21
28
  GATEWAY_DEFAULTS,
22
29
  REST_OF_WORLD_EXAMPLE_METHOD,
@@ -24,14 +31,7 @@ import {
24
31
  SETTINGS_DEFAULTS,
25
32
  TAX_CLASS_DEFAULTS,
26
33
  } from "./defaults.ts";
27
- import {
28
- SAMPLE_ATTRIBUTE_TERMS,
29
- SAMPLE_ATTRIBUTES,
30
- SAMPLE_CATEGORIES,
31
- SAMPLE_COUPONS,
32
- SAMPLE_PRODUCTS,
33
- SAMPLE_TAX_RATES,
34
- } from "./sample-data.ts";
34
+ import { CatalogPayloadError, normalizeCatalogPayload, sampleCatalog, seedCatalog } from "./seed-catalog.ts";
35
35
 
36
36
  const ok = (data: unknown, status = 200) => Response.json({ success: true, data }, { status });
37
37
  const fail = (status: number, error: string, code?: string, extra?: Record<string, unknown>) =>
@@ -45,9 +45,26 @@ Deno.serve(async (req) => {
45
45
  const body = await req.json().catch(() => ({}));
46
46
  const withSample = !!body.with_sample_data;
47
47
  const storeName = String(body.store_name ?? "").trim();
48
+ const currencyCode = String(body.currency ?? "").trim().toUpperCase();
49
+ const currencyInfo = currencyCode ? CURRENCIES.find((c) => c.code === currencyCode) : undefined;
50
+
51
+ // ── 0. validate the payload — pure, so bad input fails before canaries
52
+ // or any write ──────────────────────────────────────────────────────
53
+ if (currencyCode && !currencyInfo) {
54
+ return fail(400, "Invalid catalog payload — nothing was written.", "invalid_payload", {
55
+ errors: [{ path: "currency", error: `"${currencyCode}" is not a known ISO currency code` }],
56
+ });
57
+ }
58
+ let catalogSpec = null;
59
+ try {
60
+ catalogSpec = normalizeCatalogPayload(body);
61
+ } catch (e) {
62
+ if (e instanceof CatalogPayloadError) return fail(400, e.message, "invalid_payload", { errors: e.errors });
63
+ throw e;
64
+ }
48
65
 
49
66
  // ── 1. canary schema validation — abort before writing anything real ────
50
- const canaryErrors = await runCanaries(sr, withSample);
67
+ const canaryErrors = await runCanaries(sr, withSample || !!catalogSpec);
51
68
  if (canaryErrors.length) {
52
69
  return fail(422,
53
70
  "Entity schemas have been modified and are incompatible with the seeder.",
@@ -69,11 +86,30 @@ Deno.serve(async (req) => {
69
86
  if (existingGroups.has(group.group_id)) continue;
70
87
  const values = group.group_id === "emails"
71
88
  ? { ...group.values, store_name: storeName }
89
+ : group.group_id === "general" && currencyInfo
90
+ ? { ...group.values, currency: currencyInfo.code, num_decimals: currencyInfo.decimals }
72
91
  : group.values;
73
92
  await sr.entities["commerce.StoreSettings"].create({ ...group, values });
74
93
  seeded.settings_groups++;
75
94
  }
76
95
 
96
+ // An explicit currency always wins — unlike store_name there is no "blank"
97
+ // to distinguish a merchant's choice from the seeded USD default, and a
98
+ // caller passing one is giving an instruction, not a fallback.
99
+ let currencyAction = currencyInfo ? "created" : "unchanged";
100
+ if (currencyInfo && existingGroups.has("general")) {
101
+ const general = existingSettings.find((r: any) => r.group_id === "general");
102
+ const values = general?.values ?? {};
103
+ if (values.currency === currencyInfo.code && values.num_decimals === currencyInfo.decimals) {
104
+ currencyAction = "unchanged";
105
+ } else {
106
+ await sr.entities["commerce.StoreSettings"].update(general.id, {
107
+ values: { ...values, currency: currencyInfo.code, num_decimals: currencyInfo.decimals },
108
+ });
109
+ currencyAction = "updated";
110
+ }
111
+ }
112
+
77
113
  // A passed store_name must never be silently dropped: fill a blank one on an
78
114
  // already-seeded store, but never overwrite a name the merchant chose.
79
115
  let storeNameAction = storeName ? "created" : "unchanged";
@@ -115,22 +151,29 @@ Deno.serve(async (req) => {
115
151
  seeded.zone_methods++;
116
152
  }
117
153
 
118
- // ── 3. optional sample catalog ───────────────────────────────────────────
119
- let sampleResult: Record<string, number> | null = null;
154
+ // ── 3. the catalog: caller-supplied, or the demo sample ──────────────────
155
+ let sampleResult: Record<string, any> | null = null;
120
156
  if (withSample) {
121
157
  const anyProduct = (await sr.entities["commerce.Product"].list(undefined, 1)) ?? [];
122
158
  if (!anyProduct.length) {
123
- sampleResult = await seedSampleData(sr);
159
+ sampleResult = await seedCatalog(sr, sampleCatalog(), { skipExisting: false, errorCode: "sample_seed_failed" });
124
160
  }
125
161
  }
126
162
 
163
+ let catalogResult: Record<string, any> | null = null;
164
+ if (catalogSpec) {
165
+ catalogResult = await seedCatalog(sr, catalogSpec, { skipExisting: true, errorCode: "catalog_seed_failed" });
166
+ }
167
+
127
168
  const currentName = String(
128
169
  (existingSettings.find((r: any) => r.group_id === "emails")?.values?.store_name ?? storeName) || storeName,
129
170
  );
130
171
  return ok({
131
172
  seeded,
132
173
  sample_data: sampleResult ?? false,
174
+ catalog: catalogResult,
133
175
  store_name: { value: storeNameAction === "kept_existing" ? currentName : storeName, action: storeNameAction },
176
+ currency: currencyInfo ? { value: currencyInfo.code, action: currencyAction } : null,
134
177
  });
135
178
  } catch (e) {
136
179
  if (e instanceof HttpError) return fail(e.status, e.message, e.code);
@@ -142,7 +185,7 @@ Deno.serve(async (req) => {
142
185
  // ── canaries ─────────────────────────────────────────────────────────────────
143
186
 
144
187
  /** Minimal-valid probe records per entity the seeder writes. */
145
- function canarySpecs(withSample: boolean): Array<{ entity: string; record: Record<string, any> }> {
188
+ function canarySpecs(needsCatalog: boolean): Array<{ entity: string; record: Record<string, any> }> {
146
189
  const base = [
147
190
  { entity: "commerce.StoreSettings", record: { group_id: "general", values: { __canary: true } } },
148
191
  { entity: "commerce.PaymentGateway", record: { slug: "__canary", title: "Canary", enabled: false, order: 999, settings: {} } },
@@ -150,8 +193,9 @@ function canarySpecs(withSample: boolean): Array<{ entity: string; record: Recor
150
193
  { entity: "commerce.ShippingZone", record: { name: "__canary", order: 998, locations: [] } },
151
194
  { entity: "commerce.ShippingZoneMethod", record: { zone_id: "__canary", method_id: "flat_rate", title: "Canary", enabled: false, order: 0, settings: { cost: 0 } } },
152
195
  ];
153
- const sample = [
196
+ const catalog = [
154
197
  { entity: "commerce.ProductCategory", record: { name: "__canary", slug: "__canary", count: 0 } },
198
+ { entity: "commerce.ProductTag", record: { name: "__canary", count: 0 } },
155
199
  { entity: "commerce.ProductAttribute", record: { name: "__canary", code: "__canary", order: 0 } },
156
200
  { entity: "commerce.ProductAttributeTerm", record: { attribute_id: "__canary", name: "__canary", order: 0 } },
157
201
  { entity: "commerce.Product", record: { name: "__canary", status: "draft", regular_price: 1, price: 1 } },
@@ -159,12 +203,12 @@ function canarySpecs(withSample: boolean): Array<{ entity: string; record: Recor
159
203
  { entity: "commerce.Coupon", record: { code: "__canary", discount_type: "percent", amount: 1, usage_count: 0, used_by: [] } },
160
204
  { entity: "commerce.TaxRate", record: { country: "ZZ", rate: 1, name: "__canary", priority: 1, compound: false, shipping: true, tax_class: "standard" } },
161
205
  ];
162
- return withSample ? [...base, ...sample] : base;
206
+ return needsCatalog ? [...base, ...catalog] : base;
163
207
  }
164
208
 
165
- async function runCanaries(sr: any, withSample: boolean): Promise<Array<{ entity: string; error: string }>> {
209
+ async function runCanaries(sr: any, needsCatalog: boolean): Promise<Array<{ entity: string; error: string }>> {
166
210
  const errors: Array<{ entity: string; error: string }> = [];
167
- for (const spec of canarySpecs(withSample)) {
211
+ for (const spec of canarySpecs(needsCatalog)) {
168
212
  let created: any = null;
169
213
  try {
170
214
  created = await sr.entities[spec.entity].create(spec.record);
@@ -179,144 +223,3 @@ async function runCanaries(sr: any, withSample: boolean): Promise<Array<{ entity
179
223
  return errors;
180
224
  }
181
225
 
182
- // ── sample catalog ───────────────────────────────────────────────────────────
183
-
184
- /** price/on_sale/stock_status derivation for seeded products (mirrors commerce/admin-products). */
185
- function deriveSeedProduct(p: any): any {
186
- const out = { ...p };
187
- out.on_sale = out.sale_price != null;
188
- const effective = out.on_sale ? out.sale_price : out.regular_price;
189
- if (effective != null) out.price = round2(Number(effective));
190
- if (out.manage_stock) {
191
- const qty = Number(out.stock_quantity ?? 0);
192
- out.stock_status = qty > 0 ? "instock" : (out.backorders && out.backorders !== "no" ? "onbackorder" : "outofstock");
193
- } else if (!out.stock_status) {
194
- out.stock_status = "instock";
195
- }
196
- return out;
197
- }
198
-
199
- async function seedSampleData(sr: any): Promise<Record<string, number>> {
200
- // rollback ledger — delete in reverse order on failure
201
- const created: Array<{ entity: string; id: string }> = [];
202
- const track = async (entity: string, record: Record<string, any>) => {
203
- const rec = await sr.entities[entity].create(record);
204
- created.push({ entity, id: rec.id });
205
- return rec;
206
- };
207
-
208
- try {
209
- // categories
210
- const categoryBySlug: Record<string, any> = {};
211
- for (const cat of SAMPLE_CATEGORIES) {
212
- categoryBySlug[cat.slug] = await track("commerce.ProductCategory", cat);
213
- }
214
-
215
- // attributes + values
216
- const attributeByCode: Record<string, any> = {};
217
- for (const attr of SAMPLE_ATTRIBUTES) {
218
- const rec = await track("commerce.ProductAttribute", attr);
219
- attributeByCode[attr.code] = rec;
220
- for (const value of SAMPLE_ATTRIBUTE_TERMS[attr.code] ?? []) {
221
- await track("commerce.ProductAttributeTerm", { ...value, attribute_id: rec.id, count: 0 });
222
- }
223
- }
224
-
225
- const productByKey: Record<string, any> = {};
226
- let variationCount = 0;
227
- const specs = SAMPLE_PRODUCTS;
228
-
229
- for (const spec of specs) {
230
- const { key, categories, attributes, default_attributes, variations, options: _o, ...fields } = spec;
231
- const record: any = deriveSeedProduct({
232
- ...fields,
233
- slug: fields.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
234
- category_ids: (categories ?? []).map((slug: string) => categoryBySlug[slug]?.id).filter(Boolean),
235
- tag_ids: [],
236
- images: fields.images ?? [],
237
- meta_data: [],
238
- total_sales: 0,
239
- });
240
- if (attributes) {
241
- record.attributes = attributes.map((a: any, i: number) => ({
242
- attribute_id: attributeByCode[a.code]?.id ?? "",
243
- name: attributeByCode[a.code]?.name ?? a.code,
244
- position: i,
245
- options: a.options ?? [],
246
- }));
247
- }
248
- if (default_attributes) {
249
- record.default_attributes = default_attributes.map((d: any) => ({
250
- attribute_id: attributeByCode[d.code]?.id ?? "",
251
- name: attributeByCode[d.code]?.name ?? d.code,
252
- option: d.option,
253
- }));
254
- }
255
- const product = await track("commerce.Product", record);
256
- productByKey[key] = product;
257
-
258
- const seededVariations: any[] = [];
259
- for (const v of variations ?? []) {
260
- const attrs = Object.entries(v.options).map(([code, option]) => ({
261
- attribute_id: attributeByCode[code]?.id ?? "",
262
- name: attributeByCode[code]?.name ?? code,
263
- option,
264
- }));
265
- seededVariations.push(await track("commerce.ProductVariation", deriveSeedProduct({
266
- product_id: product.id,
267
- attributes: attrs,
268
- status: "publish",
269
- sku: `${record.sku}-${Object.values(v.options).join("-").toUpperCase()}`,
270
- regular_price: v.regular_price,
271
- ...(v.sale_price != null ? { sale_price: v.sale_price } : {}),
272
- manage_stock: "yes",
273
- stock_quantity: v.stock_quantity,
274
- backorders: v.backorders ?? "no",
275
- ...(v.image ? { image: v.image } : {}),
276
- })));
277
- variationCount++;
278
- }
279
-
280
- // The parent's price is the cheapest variant. commerce/admin-products
281
- // derives this on save, but the seeder writes entities directly — without
282
- // this a seeded variant product has no price, so it sorts as free and
283
- // drops out of every price filter.
284
- if (seededVariations.length) {
285
- const cheapest = (key: string) => {
286
- const values = seededVariations.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
287
- return values.length ? round2(Math.min(...values)) : null;
288
- };
289
- const regular = cheapest("regular_price");
290
- await sr.entities["commerce.Product"].update(product.id, {
291
- regular_price: regular,
292
- price: cheapest("price") ?? regular,
293
- on_sale: seededVariations.some((v) => !!v.on_sale),
294
- });
295
- }
296
- }
297
-
298
- // taxonomy counts for the seeded catalog
299
- for (const cat of Object.values(categoryBySlug) as any[]) {
300
- const count = specs.filter((s) => (s.categories ?? []).some((slug: string) => categoryBySlug[slug]?.id === cat.id)).length;
301
- await sr.entities["commerce.ProductCategory"].update(cat.id, { count });
302
- }
303
-
304
- for (const coupon of SAMPLE_COUPONS) await track("commerce.Coupon", coupon);
305
- for (const rate of SAMPLE_TAX_RATES) await track("commerce.TaxRate", rate);
306
-
307
- return {
308
- categories: SAMPLE_CATEGORIES.length,
309
- attributes: SAMPLE_ATTRIBUTES.length,
310
- products: SAMPLE_PRODUCTS.length,
311
- variations: variationCount,
312
- coupons: SAMPLE_COUPONS.length,
313
- tax_rates: SAMPLE_TAX_RATES.length,
314
- };
315
- } catch (e) {
316
- // best-effort rollback, newest first
317
- for (const { entity, id } of created.reverse()) {
318
- try { await sr.entities[entity].delete(id); } catch { /* leave orphans; commerce/admin-tools can clean */ }
319
- }
320
- throw new HttpError(500, `Sample data seeding failed and was rolled back: ${(e as Error).message}`, "sample_seed_failed");
321
- }
322
- }