@base44/app-plugin-commerce 0.1.3 → 0.1.5

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.
Files changed (86) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +73 -121
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +50 -166
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
  20. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  21. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  22. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  23. package/base44/shared/commerce/catalog.ts +134 -0
  24. package/base44/shared/commerce/coupons.ts +4 -7
  25. package/base44/shared/commerce/email-templates.ts +15 -13
  26. package/base44/shared/commerce/emails.ts +4 -3
  27. package/base44/shared/commerce/payments.ts +8 -20
  28. package/base44/shared/commerce/products.ts +24 -0
  29. package/base44/shared/commerce/settings.ts +2 -3
  30. package/base44/shared/commerce/stock.ts +2 -4
  31. package/base44/shared/commerce/totals.ts +37 -42
  32. package/package.json +1 -1
  33. package/scripts/install.js +23 -7
  34. package/skills/commerce/SKILL.md +9 -7
  35. package/skills/commerce/docs/api-admin.md +20 -14
  36. package/skills/commerce/docs/api-storefront.md +39 -35
  37. package/skills/commerce/installation-guidelines.md +8 -7
  38. package/skills/commerce/post-installation.md +205 -84
  39. package/skills/commerce/references/admin-product-form.md +56 -0
  40. package/skills/commerce/references/emails.md +4 -3
  41. package/skills/commerce/references/guest-access-security.md +2 -2
  42. package/skills/commerce/references/limits-and-performance.md +1 -1
  43. package/skills/commerce/references/online-payments.md +8 -8
  44. package/skills/commerce/references/product-render.md +25 -23
  45. package/skills/commerce/references/reviews.md +20 -0
  46. package/skills/commerce/references/storefront-product-page.md +9 -9
  47. package/skills/commerce/references/webhooks.md +3 -1
  48. package/src/commerce/admin/README.md +4 -4
  49. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  50. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  51. package/src/commerce/admin/index.jsx +3 -3
  52. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  53. package/src/commerce/admin/lib/constants.js +0 -7
  54. package/src/commerce/admin/lib/paths.js +6 -6
  55. package/src/commerce/admin/lib/product-utils.js +18 -15
  56. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  57. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  58. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  61. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  62. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  63. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  64. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  65. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  66. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  67. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  68. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  69. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  70. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  71. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  72. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  73. package/src/commerce/admin/routes.jsx +10 -19
  74. package/src/commerce/utils/index.js +2 -2
  75. package/src/commerce/utils/shipping-promos.js +9 -6
  76. package/src/commerce/utils/variants.js +21 -21
  77. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  78. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  79. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  80. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  81. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  82. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  83. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  84. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  85. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  86. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { createClientFromRequest } from "npm:@base44/sdk";
13
13
  import { HttpError, getCallerUser } from "../../../shared/commerce/auth.ts";
14
- import { getSetting } from "../../../shared/commerce/settings.ts";
15
14
  import { checkPurchasable } from "../../../shared/commerce/stock.ts";
16
15
  import { validateCoupon } from "../../../shared/commerce/coupons.ts";
17
16
  import { uuid } from "../../../shared/commerce/sequence.ts";
@@ -177,9 +176,6 @@ Deno.serve(async (req: Request) => {
177
176
  case "apply-coupon": {
178
177
  const cart = await loadCart(sr, payload.cart_token);
179
178
  const pricingData = await loadPricingData(sr);
180
- if (!getSetting(pricingData.settings, "general", "enable_coupons", true)) {
181
- throw new HttpError(400, "Coupons are disabled for this store.", "coupons_disabled");
182
- }
183
179
  const code = String(payload.code || "").toLowerCase().trim();
184
180
  if (!code) throw new HttpError(400, "Coupon code is required.", "code_required");
185
181
  if ((cart.coupon_codes || []).includes(code)) {
@@ -250,9 +246,6 @@ async function addItem(sr: any, cart: any, payload: any): Promise<void> {
250
246
  const { product_id, variation_id, attributes } = payload;
251
247
  let quantity = Math.max(1, Math.floor(Number(payload.quantity) || 1));
252
248
  const { product, variation } = await loadProductPair(sr, product_id, variation_id);
253
- if (product.type === "variable" && !variation) {
254
- throw new HttpError(400, "Please choose product options.", "variation_required");
255
- }
256
249
  if (product.sold_individually) quantity = 1;
257
250
 
258
251
  const items = [...(cart.items || [])];
@@ -12,6 +12,7 @@ import { createClientFromRequest } from "npm:@base44/sdk";
12
12
  import { getCallerUser, HttpError, requireUser } from "../../../shared/commerce/auth.ts";
13
13
  import { getSettings, getSetting, storefrontSafeSettings } from "../../../shared/commerce/settings.ts";
14
14
  import { isOnlineGateway, onlinePaymentStatus } from "../../../shared/commerce/payments.ts";
15
+ import { isVariable } from "../../../shared/commerce/products.ts";
15
16
  import { recalcProductRating } from "../../../shared/commerce/reviews.ts";
16
17
  import { scanAll } from "../../../shared/commerce/scan.ts";
17
18
  import { COUNTRIES } from "../../../shared/commerce/data/countries.ts";
@@ -69,8 +70,8 @@ Deno.serve(async (req: Request) => {
69
70
  * `downloads[].file_url` is the paywalled file itself — access to it is gated by
70
71
  * commerce.DownloadPermission (limits, expiry, signed URLs) via
71
72
  * commerce/storefront-account `get-download`, so it can't ride along in the
72
- * catalog. `purchase_note` is a post-purchase message, not pre-sale content.
73
- * The `downloadable` flag is kept so a shopfront can still badge the product.
73
+ * catalog. The `downloadable` flag is kept so a shopfront can still badge the
74
+ * product.
74
75
  */
75
76
  function publicProduct<T extends Record<string, any>>(product: T): T {
76
77
  if (!product || typeof product !== "object") return product;
@@ -78,7 +79,6 @@ function publicProduct<T extends Record<string, any>>(product: T): T {
78
79
  downloads: _downloads,
79
80
  download_limit: _dlLimit,
80
81
  download_expiry: _dlExpiry,
81
- purchase_note: _note,
82
82
  ...safe
83
83
  } = product as Record<string, any>;
84
84
  return safe as T;
@@ -87,7 +87,7 @@ function publicProduct<T extends Record<string, any>>(product: T): T {
87
87
  // ── list-products ────────────────────────────────────────────────────────────
88
88
 
89
89
  const SORTS: Record<string, (a: any, b: any) => number> = {
90
- "menu_order": (a, b) => (a.menu_order ?? 0) - (b.menu_order ?? 0) || String(a.name).localeCompare(String(b.name)),
90
+ "name": (a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")),
91
91
  "price": (a, b) => (a.price ?? 0) - (b.price ?? 0),
92
92
  "-price": (a, b) => (b.price ?? 0) - (a.price ?? 0),
93
93
  "-created_date": (a, b) => String(b.created_date ?? "").localeCompare(String(a.created_date ?? "")),
@@ -143,8 +143,8 @@ async function listProducts(sr: any, p: any): Promise<any> {
143
143
  if (p.featured != null) products = products.filter((prod: any) => !!prod.featured === !!p.featured);
144
144
  if (p.on_sale != null) products = products.filter((prod: any) => !!prod.on_sale === !!p.on_sale);
145
145
 
146
- const sortKey = String(p.sort ?? "menu_order");
147
- products.sort(SORTS[sortKey] ?? SORTS["menu_order"]);
146
+ const sortKey = String(p.sort ?? "-created_date");
147
+ products.sort(SORTS[sortKey] ?? SORTS["-created_date"]);
148
148
 
149
149
  const start = (page - 1) * perPage;
150
150
  const pageItems = await withTags(sr, products.slice(start, start + perPage).map(publicProduct));
@@ -160,13 +160,12 @@ async function listProducts(sr: any, p: any): Promise<any> {
160
160
  * Attach resolved `tags` to listing rows. A row otherwise carries only tag_ids,
161
161
  * which is why cards end up with no tags at all — the shape a card needs has to
162
162
  * be in the row it renders. One tag read per request, not per row; kept to
163
- * {id, name, slug} so a listing payload stays small (use list-tags for
164
- * descriptions and counts).
163
+ * {id, name} so a listing payload stays small (use list-tags for counts).
165
164
  */
166
165
  async function withTags(sr: any, rows: any[]): Promise<any[]> {
167
166
  if (!rows.some((r) => (r.tag_ids ?? []).length)) return rows;
168
167
  const all = await scanAll(sr.entities["commerce.ProductTag"], {}, "name", 2000);
169
- const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name, slug: t.slug }]));
168
+ const byId = new Map(all.map((t: any) => [t.id, { id: t.id, name: t.name }]));
170
169
  return rows.map((r) => ({
171
170
  ...r,
172
171
  tags: (r.tag_ids ?? []).map((id: string) => byId.get(id)).filter(Boolean),
@@ -205,7 +204,7 @@ async function getProduct(sr: any, p: any): Promise<any> {
205
204
  }
206
205
 
207
206
  const [variations, categories, tags] = await Promise.all([
208
- product.type === "variable"
207
+ isVariable(product)
209
208
  ? sr.entities["commerce.ProductVariation"].filter({ product_id: product.id }, "menu_order", 500)
210
209
  : Promise.resolve([]),
211
210
  resolveMany(sr.entities["commerce.ProductCategory"], product.category_ids),
@@ -225,10 +224,9 @@ async function getProduct(sr: any, p: any): Promise<any> {
225
224
  (reviewsPage - 1) * reviewsPerPage,
226
225
  )) ?? [];
227
226
 
228
- const [upsells, crossSells, grouped] = await Promise.all([
227
+ const [upsells, crossSells] = await Promise.all([
229
228
  productSummaries(sr, product.upsell_ids),
230
229
  productSummaries(sr, product.cross_sell_ids),
231
- productSummaries(sr, product.grouped_products),
232
230
  ]);
233
231
 
234
232
  return {
@@ -246,7 +244,6 @@ async function getProduct(sr: any, p: any): Promise<any> {
246
244
  },
247
245
  upsells,
248
246
  cross_sells: crossSells,
249
- grouped_products: grouped,
250
247
  };
251
248
  }
252
249
 
@@ -347,8 +344,6 @@ async function listTags(sr: any, p: any): Promise<any> {
347
344
  .map((t: any) => ({
348
345
  id: t.id,
349
346
  name: t.name,
350
- slug: t.slug,
351
- description: t.description ?? "",
352
347
  count: counts.get(t.id) ?? 0,
353
348
  }))
354
349
  .filter((t: any) => p?.with_products_only === false || t.count > 0);
@@ -356,10 +351,10 @@ async function listTags(sr: any, p: any): Promise<any> {
356
351
  }
357
352
 
358
353
  async function listAttributes(sr: any): Promise<any> {
359
- const attributes = await scanAll(sr.entities["commerce.ProductAttribute"], {}, undefined, 500);
354
+ const attributes = await scanAll(sr.entities["commerce.ProductAttribute"], {}, "order", 500);
360
355
  const out: any[] = [];
361
356
  for (const attr of attributes) {
362
- const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, "menu_order", 500)) ?? [];
357
+ const terms = (await sr.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: attr.id }, "order", 500)) ?? [];
363
358
  out.push({ ...attr, terms });
364
359
  }
365
360
  return { attributes: out };
@@ -404,10 +399,6 @@ async function submitReview(sr: any, p: any, user: any): Promise<any> {
404
399
  if (!product || product.status !== "publish") {
405
400
  throw new HttpError(404, "Product not found.", "not_found");
406
401
  }
407
- if (product.reviews_allowed === false) {
408
- throw new HttpError(403, "Reviews are disabled for this product.", "reviews_disabled");
409
- }
410
-
411
402
  const reviewerEmail = String(user.email ?? "").trim().toLowerCase();
412
403
  if (!reviewerEmail) {
413
404
  throw new HttpError(403, "Your account has no email address to review under.", "forbidden");
@@ -279,22 +279,16 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
279
279
  let paymentInstructions: any = null;
280
280
  let payment: any = null;
281
281
  switch (gateway.slug) {
282
- case "cod":
283
- await transitionOrder(sr, order, "processing", { settings });
284
- paymentInstructions = { type: "cod", description: gateway.description ?? "Pay with cash upon delivery." };
285
- break;
286
- case "bacs":
282
+ // Settled outside the store, so no money has arrived yet: on-hold, and the
283
+ // merchant moves it on once it has.
284
+ case "offline":
287
285
  await transitionOrder(sr, order, "on-hold", { settings });
288
286
  paymentInstructions = {
289
- type: "bacs",
290
- description: gateway.description ?? "Make your payment directly into our bank account.",
287
+ type: "offline",
288
+ description: gateway.description ?? "Pay outside the store. Your order is confirmed once we receive payment.",
291
289
  account_details: gateway.settings?.account_details ?? [],
292
290
  };
293
291
  break;
294
- case "cheque":
295
- await transitionOrder(sr, order, "on-hold", { settings });
296
- paymentInstructions = { type: "cheque", description: gateway.description ?? "Please send a check to our store address." };
297
- break;
298
292
  default:
299
293
  if (isOnlineGateway(gateway.slug)) {
300
294
  // Online payment: the order stays `pending` and the customer is sent to
@@ -306,7 +300,6 @@ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promi
306
300
  successUrl: payload.success_url,
307
301
  cancelUrl: payload.cancel_url,
308
302
  returnUrl: payload.return_url,
309
- storeUrl: settings.general?.store_url,
310
303
  returnPath: settings.general?.order_received_path,
311
304
  });
312
305
  const link = await startOnlinePayment(sr, order, {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Catalog write helpers shared by commerce/admin-products and
3
+ * commerce/seed-store, so both produce byte-identical records: slug/code/SKU
4
+ * derivation and uniqueness, price/stock derivation, taxonomy counts, and the
5
+ * variant parent's price/stock rollup.
6
+ */
7
+ import { HttpError } from "./auth.ts";
8
+ import { round2 } from "./money.ts";
9
+ import { deriveStockStatus } from "./stock.ts";
10
+ import { isVariable } from "./products.ts";
11
+
12
+ export function slugify(name: string): string {
13
+ return String(name || "")
14
+ .toLowerCase()
15
+ .trim()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "") || "product";
18
+ }
19
+
20
+ /** price/on_sale from the sale window. Mutates rec. */
21
+ export function derivePricing(rec: any): void {
22
+ const now = Date.now();
23
+ const from = rec.date_on_sale_from ? new Date(rec.date_on_sale_from).getTime() : -Infinity;
24
+ const to = rec.date_on_sale_to ? new Date(rec.date_on_sale_to).getTime() : Infinity;
25
+ const saleActive = rec.sale_price != null && rec.sale_price !== "" && now >= from && now <= to;
26
+ rec.on_sale = !!saleActive;
27
+ const effective = saleActive ? rec.sale_price : rec.regular_price;
28
+ if (effective != null && effective !== "") rec.price = round2(Number(effective));
29
+ }
30
+
31
+ /** stock_status derivation when stock is managed. Mutates rec. */
32
+ export function deriveStock(rec: any, outThreshold: number, managed: boolean): void {
33
+ if (managed) {
34
+ rec.stock_status = deriveStockStatus(
35
+ Number(rec.stock_quantity ?? 0),
36
+ rec.backorders ?? "no",
37
+ outThreshold,
38
+ );
39
+ } else if (!rec.stock_status) {
40
+ rec.stock_status = "instock";
41
+ }
42
+ }
43
+
44
+ export async function ensureUniqueSlug(
45
+ sr: any,
46
+ slug: string,
47
+ selfId?: string,
48
+ entity = "commerce.Product",
49
+ scope?: Record<string, any>,
50
+ ): Promise<string> {
51
+ let candidate = slug;
52
+ for (let i = 2; i < 100; i++) {
53
+ const hits = (await sr.entities[entity].filter({ ...(scope ?? {}), slug: candidate }, undefined, 2)) ?? [];
54
+ if (!hits.some((p: any) => p.id !== selfId)) return candidate;
55
+ candidate = `${slug}-${i}`;
56
+ }
57
+ return `${slug}-${crypto.randomUUID().slice(0, 6)}`;
58
+ }
59
+
60
+ /** `code` is what a storefront filter URL carries, so it has to be unique. */
61
+ export async function ensureUniqueCode(sr: any, code: string, selfId?: string): Promise<string> {
62
+ let candidate = code;
63
+ for (let i = 2; i < 100; i++) {
64
+ const hits = (await sr.entities["commerce.ProductAttribute"].filter({ code: candidate }, undefined, 2)) ?? [];
65
+ if (!hits.some((a: any) => a.id !== selfId)) return candidate;
66
+ candidate = `${code}-${i}`;
67
+ }
68
+ return `${code}-${crypto.randomUUID().slice(0, 6)}`;
69
+ }
70
+
71
+ /** SKU must be unique across products AND variations. Throws duplicate_sku. */
72
+ export async function assertUniqueSku(sr: any, sku: string, opts: { productId?: string; variationId?: string }): Promise<void> {
73
+ if (!sku) return;
74
+ const prods = (await sr.entities["commerce.Product"].filter({ sku }, undefined, 2)) ?? [];
75
+ if (prods.some((p: any) => p.id !== opts.productId)) {
76
+ throw new HttpError(409, `SKU "${sku}" is already in use by another product.`, "duplicate_sku");
77
+ }
78
+ const vars = (await sr.entities["commerce.ProductVariation"].filter({ sku }, undefined, 2)) ?? [];
79
+ if (vars.some((v: any) => v.id !== opts.variationId)) {
80
+ throw new HttpError(409, `SKU "${sku}" is already in use by a product variation.`, "duplicate_sku");
81
+ }
82
+ }
83
+
84
+ export async function adjustTermCounts(sr: any, entity: string, prevIds: string[], nextIds: string[]): Promise<void> {
85
+ const added = nextIds.filter((id) => !prevIds.includes(id));
86
+ const removed = prevIds.filter((id) => !nextIds.includes(id));
87
+ for (const id of added) await bumpCount(sr, entity, id, +1);
88
+ for (const id of removed) await bumpCount(sr, entity, id, -1);
89
+ }
90
+
91
+ export async function bumpCount(sr: any, entity: string, id: string, delta: number): Promise<void> {
92
+ try {
93
+ const rec = await sr.entities[entity].get(id);
94
+ if (rec) await sr.entities[entity].update(id, { count: Math.max(0, (rec.count ?? 0) + delta) });
95
+ } catch { /* stale reference — recount-terms repairs */ }
96
+ }
97
+
98
+ /**
99
+ * Roll a variant parent's stock and price up from its variations.
100
+ *
101
+ * Price matters beyond display: `list-products` sorts and filters on
102
+ * `product.price`, so a parent left empty sorts as free and drops out of every
103
+ * price filter. Deriving it removes a number nobody could keep in sync by hand —
104
+ * the parent is simply the cheapest thing a customer can actually buy.
105
+ */
106
+ export async function rollUpParent(sr: any, parent: any, variations: any[]): Promise<void> {
107
+ if (!isVariable(parent)) return;
108
+ const live = variations.filter((v) => (v.status ?? "publish") === "publish");
109
+ const patch: Record<string, any> = {};
110
+
111
+ // manage_stock on the parent means the merchant tracks one pooled quantity,
112
+ // and with it off an explicit stock_status is their own choice to keep.
113
+ if (!parent.manage_stock) {
114
+ const status = live.length && live.every((v) => v.stock_status === "outofstock")
115
+ ? "outofstock"
116
+ : "instock";
117
+ if (status !== parent.stock_status) patch.stock_status = status;
118
+ }
119
+
120
+ const cheapest = (key: string): number | null => {
121
+ const values = live.map((v) => Number(v[key])).filter((n) => Number.isFinite(n));
122
+ return values.length ? round2(Math.min(...values)) : null;
123
+ };
124
+ const regular = cheapest("regular_price");
125
+ const price = cheapest("price") ?? regular;
126
+ const onSale = live.some((v) => !!v.on_sale);
127
+ if (regular !== (parent.regular_price ?? null)) patch.regular_price = regular;
128
+ if (price !== (parent.price ?? null)) patch.price = price;
129
+ if (onSale !== !!parent.on_sale) patch.on_sale = onSale;
130
+
131
+ if (!Object.keys(patch).length) return;
132
+ await sr.entities["commerce.Product"].update(parent.id, patch);
133
+ Object.assign(parent, patch);
134
+ }
@@ -148,19 +148,16 @@ export interface CouponLineTotal {
148
148
  }
149
149
 
150
150
  /**
151
- * Apply coupons in order, mutating each line's `.discount`.
152
- * `calc_discounts_sequentially` (general settings): when true each coupon sees
153
- * the line total minus prior discounts; when false all see the original
154
- * subtotal (the default). Line discounts never exceed the line's remaining value.
151
+ * Apply coupons in order, mutating each line's `.discount`. Every coupon is
152
+ * computed off the original line subtotal, not the running total. Line
153
+ * discounts never exceed the line's remaining value.
155
154
  */
156
155
  export function applyCoupons(
157
156
  lines: CouponLineInput[],
158
157
  coupons: any[],
159
- settings: Record<string, any>,
160
158
  ): CouponLineTotal[] {
161
- const sequential = !!settings?.general?.calc_discounts_sequentially;
162
159
  const couponLines: CouponLineTotal[] = [];
163
- const base = (l: CouponLineInput) => Math.max(0, sequential ? l.subtotal - l.discount : l.subtotal);
160
+ const base = (l: CouponLineInput) => Math.max(0, l.subtotal);
164
161
  const remaining = (l: CouponLineInput) => Math.max(0, round2(l.subtotal - l.discount));
165
162
 
166
163
  for (const coupon of coupons || []) {
@@ -66,12 +66,12 @@ const DEFAULT_COPY: Record<string, EmailCopy> = {
66
66
  },
67
67
  };
68
68
 
69
- function substitute(text: string, order: any, general: Record<string, any>): string {
69
+ function substitute(text: string, order: any, storeName: string, general: Record<string, any>): string {
70
70
  const customerName = [order.billing?.first_name, order.billing?.last_name].filter(Boolean).join(" ") || "customer";
71
71
  const out = (text || "")
72
72
  .replaceAll("{order_number}", String(order.order_number ?? ""))
73
73
  .replaceAll("{customer_name}", customerName)
74
- .replaceAll("{store_name}", String(general.store_name || ""))
74
+ .replaceAll("{store_name}", storeName)
75
75
  .replaceAll("{order_total}", formatMoney(order.total ?? 0, general));
76
76
  // An unnamed store must not leave debris behind: "[]: New order", "Your order",
77
77
  // "Invoice #12 from ". A missing name drops out of the sentence instead.
@@ -89,7 +89,7 @@ function esc(s: unknown): string {
89
89
  const cell = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;"';
90
90
  const cellR = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;text-align:right;"';
91
91
 
92
- function addressBlock(title: string, a: any, general: Record<string, any>): string {
92
+ function addressBlock(title: string, a: any): string {
93
93
  if (!a || (!a.address_1 && !a.first_name && !a.city)) return "";
94
94
  const lines = [
95
95
  [a.first_name, a.last_name].filter(Boolean).join(" "),
@@ -121,8 +121,8 @@ export interface RenderedEmail {
121
121
  }
122
122
 
123
123
  /**
124
- * Render an order email. `settings` is the groups object (uses `emails` for
125
- * overrides and `general` for store name / money format).
124
+ * Render an order email. `settings` is the groups object (uses `emails` for the
125
+ * store name and per-type overrides, `general` for money format).
126
126
  */
127
127
  export function renderOrderEmail(
128
128
  type: string,
@@ -131,12 +131,14 @@ export function renderOrderEmail(
131
131
  extra: RenderExtra = {},
132
132
  ): RenderedEmail {
133
133
  const general = settings.general ?? {};
134
- const typeCfg = (settings.emails ?? {})[type] ?? {};
134
+ const emails = settings.emails ?? {};
135
+ const storeName = String(emails.store_name || "");
136
+ const typeCfg = emails[type] ?? {};
135
137
  const copy = DEFAULT_COPY[type] ?? DEFAULT_COPY.customer_invoice;
136
138
 
137
- const subject = substitute(typeCfg.subject || copy.subject, order, general);
138
- const heading = substitute(typeCfg.heading || copy.heading, order, general);
139
- const intro = substitute(copy.intro, order, general);
139
+ const subject = substitute(typeCfg.subject || copy.subject, order, storeName, general);
140
+ const heading = substitute(typeCfg.heading || copy.heading, order, storeName, general);
141
+ const intro = substitute(copy.intro, order, storeName, general);
140
142
  const money = (n: number) => esc(formatMoney(n ?? 0, general));
141
143
 
142
144
  const itemRows = (order.line_items || []).map((li: any) => `
@@ -197,7 +199,7 @@ export function renderOrderEmail(
197
199
  <table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:6px;overflow:hidden;margin:0 auto;">
198
200
  <tr>
199
201
  <td style="background:#557da1;padding:24px;">
200
- <h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600;">${esc(general.store_name || "Your order")}</h1>
202
+ <h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600;">${esc(storeName || "Your order")}</h1>
201
203
  </td>
202
204
  </tr>
203
205
  <tr>
@@ -218,15 +220,15 @@ export function renderOrderEmail(
218
220
  ${payHtml}${customerNoteHtml}${additional}
219
221
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;">
220
222
  <tr>
221
- ${addressBlock("Billing address", order.billing, general)}
222
- ${addressBlock("Shipping address", order.shipping, general)}
223
+ ${addressBlock("Billing address", order.billing)}
224
+ ${addressBlock("Shipping address", order.shipping)}
223
225
  </tr>
224
226
  </table>
225
227
  </td>
226
228
  </tr>
227
229
  <tr>
228
230
  <td style="padding:16px 24px;background:#fafafa;border-top:1px solid #eee;">
229
- <p style="margin:0;font-size:12px;color:#999;">${general.store_name ? `${esc(general.store_name)} — ` : ""}powered by the Base44 commerce template</p>
231
+ <p style="margin:0;font-size:12px;color:#999;">${storeName ? `${esc(storeName)} — ` : ""}powered by the Base44 commerce template</p>
230
232
  </td>
231
233
  </tr>
232
234
  </table>
@@ -120,7 +120,8 @@ export async function sendOrderEmail(
120
120
  }
121
121
 
122
122
  const { subject, html } = renderOrderEmail(type, order, settings, opts.extra ?? {});
123
- const fromName = cfg.from_name || settings.general?.store_name || undefined;
123
+ // Blank sends without from_name, in which case Base44 sends as the app's name.
124
+ const fromName = cfg.store_name || undefined;
124
125
 
125
126
  let anySent = false;
126
127
  for (const { to, target } of recipients) {
@@ -172,7 +173,7 @@ export async function sendStockEmail(sr: any, kind: string, product: any, opts:
172
173
  return;
173
174
  }
174
175
 
175
- const storeName = settings.general?.store_name || "";
176
+ const storeName = settings.emails?.store_name || "";
176
177
  const labels: Record<string, string> = {
177
178
  low_stock: "is low in stock",
178
179
  out_of_stock: "is out of stock",
@@ -182,7 +183,7 @@ export async function sendStockEmail(sr: any, kind: string, product: any, opts:
182
183
  const body = `<p><strong>${product.name}</strong> (${product.sku || "no SKU"}) ${labels[kind] ?? kind}.</p>
183
184
  <p>Remaining stock: ${product.stock_quantity ?? "n/a"}</p>`;
184
185
  try {
185
- await sr.integrations.Core.SendEmail({ to, subject, body, from_name: settings.emails?.from_name });
186
+ await sr.integrations.Core.SendEmail({ to, subject, body, from_name: storeName || undefined });
186
187
  await logEmail(sr, { type: kind, recipient: to, target: "admin", subject, success: true });
187
188
  } catch (e) {
188
189
  await logEmail(sr, {
@@ -194,23 +194,12 @@ export function isAbsoluteUrl(value: unknown): boolean {
194
194
  }
195
195
 
196
196
  /**
197
- * First candidate that is a usable absolute origin, without a trailing slash.
197
+ * The origin of the caller, from the request headers ("" when absent).
198
198
  *
199
199
  * Providers reject relative paths ("Not a valid URL"), so the return URLs have
200
- * to be built from a real origin. Callers pass their candidates in order of
201
- * authority — an explicit `return_url`, the store's configured URL, then the
202
- * origin the request came from (the admin or storefront calling this lives on the
203
- * app's own host, which is exactly where the customer should land back).
200
+ * to be built from a real origin — and the storefront calling this is exactly
201
+ * where the customer should land back.
204
202
  */
205
- export function resolveReturnBase(candidates: Array<unknown>): string {
206
- for (const candidate of candidates) {
207
- const value = String(candidate ?? "").trim();
208
- if (value && isAbsoluteUrl(value)) return value.replace(/\/+$/, "");
209
- }
210
- return "";
211
- }
212
-
213
- /** The origin of the caller, from the request headers ("" when absent). */
214
203
  export function requestOrigin(req: Request): string {
215
204
  const origin = req.headers.get("origin");
216
205
  if (origin && isAbsoluteUrl(origin)) return origin;
@@ -254,8 +243,8 @@ export function orderReturnUrl(base: string, order: any, outcome: "success" | "c
254
243
  * to fix it — better than letting the provider answer "Not a valid URL".
255
244
  *
256
245
  * Precedence: `successUrl`/`cancelUrl` per outcome → `returnUrl` (the return
257
- * page, both outcomes) → `storeUrl`/request origin + `returnPath`. Every result
258
- * carries `order_id`, `order_key` and `payment`.
246
+ * page, both outcomes) → the calling storefront's own origin + `returnPath`.
247
+ * Every result carries `order_id`, `order_key` and `payment`.
259
248
  */
260
249
  export function resolveReturnUrls(opts: {
261
250
  order: any;
@@ -263,10 +252,9 @@ export function resolveReturnUrls(opts: {
263
252
  successUrl?: unknown;
264
253
  cancelUrl?: unknown;
265
254
  returnUrl?: unknown;
266
- storeUrl?: unknown;
267
255
  returnPath?: unknown;
268
256
  }): { successUrl: string; cancelUrl: string } {
269
- const base = resolveReturnBase([opts.storeUrl, opts.req ? requestOrigin(opts.req) : ""]);
257
+ const base = (opts.req ? requestOrigin(opts.req) : "").replace(/\/+$/, "");
270
258
 
271
259
  // A `returnUrl` naming a page is used as-is; a bare origin has no page in it,
272
260
  // so it is treated as a base and gets the store's return path appended.
@@ -289,9 +277,9 @@ export function resolveReturnUrls(opts: {
289
277
  if (!success || !cancel) {
290
278
  throw new HttpError(
291
279
  400,
292
- "Can't build the return URLs for the payment page: pass an absolute success_url/cancel_url (or return_url pointing at your return page), or set the store URL in the store's general settings.",
280
+ "Can't build the return URLs for the payment page: the request carried no origin to return to, so pass an absolute success_url/cancel_url (or a return_url pointing at your return page).",
293
281
  "return_url_required",
294
- { needs: ["success_url", "cancel_url"], hint: "settings.general.store_url + order_received_path" },
282
+ { needs: ["success_url", "cancel_url"], hint: "request origin + settings.general.order_received_path" },
295
283
  );
296
284
  }
297
285
  return { successUrl: success, cancelUrl: cancel };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Product shape predicates.
3
+ *
4
+ * There is no product `type` field: a product sells variants because it carries
5
+ * attributes, and every entry in `attributes[]` is a variant axis. Attaching an
6
+ * attribute is the whole declaration — which also means a product with an axis
7
+ * but no variations is deliberately unsellable (`variation_required`) rather
8
+ * than quietly falling back to the parent price.
9
+ *
10
+ * Descriptive properties are not attributes; they live in `meta_data` (the
11
+ * admin calls them modifiers) and never affect price or stock.
12
+ */
13
+
14
+ /** Does this product sell variants? */
15
+ export function isVariable(product: any): boolean {
16
+ return (product?.attributes ?? []).length > 0;
17
+ }
18
+
19
+ /** The variant axes, in the order the storefront should present them. */
20
+ export function variationAxes(product: any): any[] {
21
+ return (product?.attributes ?? [])
22
+ .slice()
23
+ .sort((a: any, b: any) => (a?.position ?? 0) - (b?.position ?? 0));
24
+ }
@@ -37,14 +37,13 @@ export function storefrontSafeSettings(groups: Record<string, any>): Record<stri
37
37
  const tax = groups.tax ?? {};
38
38
  const inventory = groups.inventory ?? {};
39
39
  return {
40
- store_name: general.store_name ?? "",
40
+ // The one key taken from the emails group: the store's own name is public.
41
+ store_name: groups.emails?.store_name ?? "",
41
42
  currency: general.currency ?? "USD",
42
43
  currency_position: general.currency_position ?? "left",
43
44
  thousand_sep: general.thousand_sep ?? ",",
44
45
  decimal_sep: general.decimal_sep ?? ".",
45
46
  num_decimals: general.num_decimals ?? 2,
46
- enable_taxes: general.enable_taxes ?? true,
47
- enable_coupons: general.enable_coupons ?? true,
48
47
  weight_unit: products.weight_unit ?? "kg",
49
48
  dimension_unit: products.dimension_unit ?? "cm",
50
49
  enable_reviews: products.enable_reviews ?? true,
@@ -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.type === "variable" && !variation) {
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)." };