@base44/app-plugin-commerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Common currencies (code, name, symbol, standard minor-unit decimals).
3
+ * A reference /data/currencies dataset (subset of the
4
+ * most-used currencies; extend freely — this is plain static data).
5
+ */
6
+ export interface CurrencyInfo {
7
+ code: string;
8
+ name: string;
9
+ symbol: string;
10
+ decimals: number;
11
+ }
12
+
13
+ export const CURRENCIES: CurrencyInfo[] = [
14
+ { code: "USD", name: "US Dollar", symbol: "$", decimals: 2 },
15
+ { code: "EUR", name: "Euro", symbol: "€", decimals: 2 },
16
+ { code: "GBP", name: "Pound Sterling", symbol: "£", decimals: 2 },
17
+ { code: "JPY", name: "Japanese Yen", symbol: "¥", decimals: 0 },
18
+ { code: "CNY", name: "Chinese Yuan", symbol: "¥", decimals: 2 },
19
+ { code: "AUD", name: "Australian Dollar", symbol: "A$", decimals: 2 },
20
+ { code: "CAD", name: "Canadian Dollar", symbol: "C$", decimals: 2 },
21
+ { code: "CHF", name: "Swiss Franc", symbol: "CHF", decimals: 2 },
22
+ { code: "HKD", name: "Hong Kong Dollar", symbol: "HK$", decimals: 2 },
23
+ { code: "SGD", name: "Singapore Dollar", symbol: "S$", decimals: 2 },
24
+ { code: "SEK", name: "Swedish Krona", symbol: "kr", decimals: 2 },
25
+ { code: "NOK", name: "Norwegian Krone", symbol: "kr", decimals: 2 },
26
+ { code: "DKK", name: "Danish Krone", symbol: "kr", decimals: 2 },
27
+ { code: "NZD", name: "New Zealand Dollar", symbol: "NZ$", decimals: 2 },
28
+ { code: "ILS", name: "Israeli New Shekel", symbol: "₪", decimals: 2 },
29
+ { code: "AED", name: "UAE Dirham", symbol: "د.إ", decimals: 2 },
30
+ { code: "INR", name: "Indian Rupee", symbol: "₹", decimals: 2 },
31
+ { code: "BRL", name: "Brazilian Real", symbol: "R$", decimals: 2 },
32
+ { code: "MXN", name: "Mexican Peso", symbol: "MX$", decimals: 2 },
33
+ { code: "ZAR", name: "South African Rand", symbol: "R", decimals: 2 },
34
+ { code: "PLN", name: "Polish Złoty", symbol: "zł", decimals: 2 },
35
+ { code: "CZK", name: "Czech Koruna", symbol: "Kč", decimals: 2 },
36
+ { code: "HUF", name: "Hungarian Forint", symbol: "Ft", decimals: 0 },
37
+ { code: "RON", name: "Romanian Leu", symbol: "lei", decimals: 2 },
38
+ { code: "TRY", name: "Turkish Lira", symbol: "₺", decimals: 2 },
39
+ { code: "RUB", name: "Russian Ruble", symbol: "₽", decimals: 2 },
40
+ { code: "KRW", name: "South Korean Won", symbol: "₩", decimals: 0 },
41
+ { code: "THB", name: "Thai Baht", symbol: "฿", decimals: 2 },
42
+ { code: "PHP", name: "Philippine Peso", symbol: "₱", decimals: 2 },
43
+ { code: "MYR", name: "Malaysian Ringgit", symbol: "RM", decimals: 2 },
44
+ { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp", decimals: 0 },
45
+ { code: "VND", name: "Vietnamese Dong", symbol: "₫", decimals: 0 },
46
+ ];
@@ -0,0 +1,240 @@
1
+ /**
2
+ * HTML rendering for transactional order emails. Simple, inline-styled,
3
+ * 600px table layout that renders acceptably in every mail client.
4
+ * Subjects/headings follow standard commerce conventions and can be overridden per type
5
+ * in the `emails` settings group.
6
+ */
7
+ import { formatMoney } from "./money.ts";
8
+
9
+ interface EmailCopy {
10
+ subject: string;
11
+ heading: string;
12
+ intro: string;
13
+ }
14
+
15
+ /** Default copy per email type; {placeholders} substituted at render time. */
16
+ const DEFAULT_COPY: Record<string, EmailCopy> = {
17
+ new_order: {
18
+ subject: "[{store_name}]: New order #{order_number}",
19
+ heading: "New Order: #{order_number}",
20
+ intro: "You have received the following order from {customer_name}:",
21
+ },
22
+ cancelled_order: {
23
+ subject: "[{store_name}]: Order #{order_number} has been cancelled",
24
+ heading: "Order Cancelled: #{order_number}",
25
+ intro: "The following order has been cancelled. Order details:",
26
+ },
27
+ failed_order: {
28
+ subject: "[{store_name}]: Order #{order_number} has failed",
29
+ heading: "Order Failed: #{order_number}",
30
+ intro: "Payment for the following order has failed. Order details:",
31
+ },
32
+ on_hold_order: {
33
+ subject: "Your {store_name} order has been received!",
34
+ heading: "Thank you for your order",
35
+ intro: "Your order is on hold until we confirm that payment has been received. Order details below for your reference:",
36
+ },
37
+ processing_order: {
38
+ subject: "Your {store_name} order has been received!",
39
+ heading: "Thank you for your order",
40
+ intro: "We have received your order and it is now being processed. Order details below for your reference:",
41
+ },
42
+ completed_order: {
43
+ subject: "Your {store_name} order is now complete",
44
+ heading: "Thanks for shopping with us",
45
+ intro: "Your order is now complete. Order details below for your reference:",
46
+ },
47
+ refunded_order: {
48
+ subject: "Your {store_name} order has been refunded",
49
+ heading: "Order Refunded: #{order_number}",
50
+ intro: "Your order has been refunded. Details below for your reference:",
51
+ },
52
+ partial_refund: {
53
+ subject: "Your {store_name} order has been partially refunded",
54
+ heading: "Order Partially Refunded: #{order_number}",
55
+ intro: "Your order has been partially refunded. Details below for your reference:",
56
+ },
57
+ customer_invoice: {
58
+ subject: "Invoice for order #{order_number} from {store_name}",
59
+ heading: "Invoice for order #{order_number}",
60
+ intro: "Details of your order are below:",
61
+ },
62
+ customer_note: {
63
+ subject: "Note added to your {store_name} order #{order_number}",
64
+ heading: "A note has been added to your order",
65
+ intro: "The following note has been added to your order:",
66
+ },
67
+ };
68
+
69
+ function substitute(text: string, order: any, general: Record<string, any>): string {
70
+ const customerName = [order.billing?.first_name, order.billing?.last_name].filter(Boolean).join(" ") || "customer";
71
+ const out = (text || "")
72
+ .replaceAll("{order_number}", String(order.order_number ?? ""))
73
+ .replaceAll("{customer_name}", customerName)
74
+ .replaceAll("{store_name}", String(general.store_name || ""))
75
+ .replaceAll("{order_total}", formatMoney(order.total ?? 0, general));
76
+ // An unnamed store must not leave debris behind: "[]: New order", "Your order",
77
+ // "Invoice #12 from ". A missing name drops out of the sentence instead.
78
+ return out
79
+ .replace(/\[\s*\]\s*:?\s*/g, "")
80
+ .replace(/\s+from\s*$/i, "")
81
+ .replace(/\s{2,}/g, " ")
82
+ .trim();
83
+ }
84
+
85
+ function esc(s: unknown): string {
86
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
87
+ }
88
+
89
+ const cell = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;"';
90
+ const cellR = 'style="padding:8px 12px;border-bottom:1px solid #e5e5e5;font-size:14px;color:#333;text-align:right;"';
91
+
92
+ function addressBlock(title: string, a: any, general: Record<string, any>): string {
93
+ if (!a || (!a.address_1 && !a.first_name && !a.city)) return "";
94
+ const lines = [
95
+ [a.first_name, a.last_name].filter(Boolean).join(" "),
96
+ a.company,
97
+ a.address_1,
98
+ a.address_2,
99
+ [a.city, a.state, a.postcode].filter(Boolean).join(", "),
100
+ a.country,
101
+ a.email,
102
+ a.phone,
103
+ ].filter(Boolean).map(esc);
104
+ return `<td style="vertical-align:top;padding:12px;width:50%;">
105
+ <h3 style="margin:0 0 8px;font-size:15px;color:#333;">${esc(title)}</h3>
106
+ <p style="margin:0;font-size:13px;color:#555;line-height:1.5;">${lines.join("<br/>")}</p>
107
+ </td>`;
108
+ }
109
+
110
+ export interface RenderExtra {
111
+ note?: string;
112
+ refund?: { amount: number; reason?: string };
113
+ paymentInstructions?: { title: string; lines: string[] };
114
+ additionalContent?: string;
115
+ }
116
+
117
+ export interface RenderedEmail {
118
+ subject: string;
119
+ heading: string;
120
+ html: string;
121
+ }
122
+
123
+ /**
124
+ * Render an order email. `settings` is the groups object (uses `emails` for
125
+ * overrides and `general` for store name / money format).
126
+ */
127
+ export function renderOrderEmail(
128
+ type: string,
129
+ order: any,
130
+ settings: Record<string, any>,
131
+ extra: RenderExtra = {},
132
+ ): RenderedEmail {
133
+ const general = settings.general ?? {};
134
+ const typeCfg = (settings.emails ?? {})[type] ?? {};
135
+ const copy = DEFAULT_COPY[type] ?? DEFAULT_COPY.customer_invoice;
136
+
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);
140
+ const money = (n: number) => esc(formatMoney(n ?? 0, general));
141
+
142
+ const itemRows = (order.line_items || []).map((li: any) => `
143
+ <tr>
144
+ <td ${cell}>${esc(li.name)}${li.sku ? ` <span style="color:#999;">(${esc(li.sku)})</span>` : ""}${
145
+ (li.attributes || []).length
146
+ ? `<br/><span style="color:#777;font-size:12px;">${li.attributes.map((a: any) => `${esc(a.name)}: ${esc(a.option)}`).join(", ")}</span>`
147
+ : ""
148
+ }</td>
149
+ <td ${cellR}>${li.quantity}</td>
150
+ <td ${cellR}>${money(li.total)}</td>
151
+ </tr>`).join("");
152
+
153
+ const totalsRows: Array<[string, string]> = [["Subtotal", money(order.subtotal ?? 0)]];
154
+ if (order.discount_total) totalsRows.push(["Discount", `-${money(order.discount_total)}`]);
155
+ for (const fl of order.fee_lines || []) totalsRows.push([esc(fl.name || "Fee"), money(fl.total)]);
156
+ if ((order.shipping_lines || []).length) {
157
+ const title = order.shipping_lines.map((s: any) => esc(s.method_title)).join(", ");
158
+ totalsRows.push([`Shipping (${title})`, money(order.shipping_total ?? 0)]);
159
+ }
160
+ for (const tl of order.tax_lines || []) {
161
+ totalsRows.push([esc(tl.label || "Tax"), money(round2Safe(tl.tax_total + tl.shipping_tax_total))]);
162
+ }
163
+ totalsRows.push(["<strong>Total</strong>", `<strong>${money(order.total ?? 0)}</strong>`]);
164
+ if (order.total_refunded) totalsRows.push(["Refunded", `-${money(order.total_refunded)}`]);
165
+
166
+ const totalsHtml = totalsRows.map(([label, value]) => `
167
+ <tr>
168
+ <td colspan="2" ${cellR}>${label}</td>
169
+ <td ${cellR}>${value}</td>
170
+ </tr>`).join("");
171
+
172
+ const noteHtml = extra.note
173
+ ? `<div style="margin:16px 0;padding:12px;background:#fdf6e3;border:1px solid #f0e0b0;border-radius:4px;font-size:13px;color:#555;">${esc(extra.note)}</div>`
174
+ : "";
175
+
176
+ const refundHtml = extra.refund
177
+ ? `<p style="font-size:13px;color:#555;">Refund amount: <strong>${money(extra.refund.amount)}</strong>${extra.refund.reason ? ` — ${esc(extra.refund.reason)}` : ""}</p>`
178
+ : "";
179
+
180
+ const payHtml = extra.paymentInstructions
181
+ ? `<div style="margin:16px 0;padding:12px;background:#f5f8fb;border:1px solid #d4e0ec;border-radius:4px;">
182
+ <h3 style="margin:0 0 8px;font-size:14px;color:#333;">${esc(extra.paymentInstructions.title)}</h3>
183
+ <p style="margin:0;font-size:13px;color:#555;line-height:1.6;">${extra.paymentInstructions.lines.map(esc).join("<br/>")}</p>
184
+ </div>`
185
+ : "";
186
+
187
+ const customerNoteHtml = order.customer_note
188
+ ? `<p style="font-size:13px;color:#555;"><strong>Customer note:</strong> ${esc(order.customer_note)}</p>`
189
+ : "";
190
+
191
+ const additional = typeCfg.additional_content || extra.additionalContent
192
+ ? `<p style="font-size:13px;color:#555;">${esc(typeCfg.additional_content || extra.additionalContent)}</p>`
193
+ : "";
194
+
195
+ const html = `
196
+ <div style="background:#f7f7f7;padding:24px 0;font-family:Helvetica,Arial,sans-serif;">
197
+ <table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:6px;overflow:hidden;margin:0 auto;">
198
+ <tr>
199
+ <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>
201
+ </td>
202
+ </tr>
203
+ <tr>
204
+ <td style="padding:24px;">
205
+ <h2 style="margin:0 0 12px;font-size:18px;color:#557da1;">${esc(heading)}</h2>
206
+ <p style="font-size:14px;color:#555;line-height:1.5;">${esc(intro)}</p>
207
+ ${noteHtml}${refundHtml}
208
+ <h3 style="margin:20px 0 8px;font-size:15px;color:#333;">Order #${esc(order.order_number)} (${esc((order.created_date || "").slice(0, 10))})</h3>
209
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e5e5;border-collapse:collapse;">
210
+ <tr>
211
+ <th ${cell.replace("color:#333", "color:#333;text-align:left")}>Product</th>
212
+ <th ${cellR}>Qty</th>
213
+ <th ${cellR}>Total</th>
214
+ </tr>
215
+ ${itemRows}
216
+ ${totalsHtml}
217
+ </table>
218
+ ${payHtml}${customerNoteHtml}${additional}
219
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;">
220
+ <tr>
221
+ ${addressBlock("Billing address", order.billing, general)}
222
+ ${addressBlock("Shipping address", order.shipping, general)}
223
+ </tr>
224
+ </table>
225
+ </td>
226
+ </tr>
227
+ <tr>
228
+ <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>
230
+ </td>
231
+ </tr>
232
+ </table>
233
+ </div>`;
234
+
235
+ return { subject, heading, html };
236
+ }
237
+
238
+ function round2Safe(n: number): number {
239
+ return Math.round(((Number(n) || 0) + Number.EPSILON) * 100) / 100;
240
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Transactional email dispatch (the standard commerce email set, minus reset_password /
3
+ * new_account which Base44 auth handles). Reads the `emails` settings group,
4
+ * renders via email-templates.ts, sends with Core.SendEmail, logs to EmailLog,
5
+ * and records lifecycle sends on order.emails_sent to prevent duplicates.
6
+ *
7
+ * Persistence note: this module OWNS order.emails_sent (it updates that field
8
+ * on the Order record itself); orders.ts transitionOrder never writes it.
9
+ */
10
+ import { getSettings } from "./settings.ts";
11
+ import { renderOrderEmail, RenderExtra } from "./email-templates.ts";
12
+
13
+ /** Types sent to store admins. */
14
+ const ADMIN_TYPES = new Set(["new_order", "cancelled_order", "failed_order"]);
15
+ /** Types sent to the customer. */
16
+ const CUSTOMER_TYPES = new Set([
17
+ "failed_order", "on_hold_order", "processing_order", "completed_order",
18
+ "refunded_order", "partial_refund", "customer_invoice", "customer_note",
19
+ ]);
20
+ /** Lifecycle types that only ever fire once per order (deduped via emails_sent). */
21
+ const ONCE_TYPES = new Set([
22
+ "new_order", "cancelled_order", "failed_order", "on_hold_order",
23
+ "processing_order", "completed_order", "refunded_order",
24
+ ]);
25
+
26
+ interface Recipient {
27
+ to: string;
28
+ target: "admin" | "customer";
29
+ }
30
+
31
+ /** Settings hold either an array (admin_recipients) or a comma-separated string. */
32
+ function toAddresses(value: any): string[] {
33
+ const parts = Array.isArray(value) ? value : String(value ?? "").split(/[,;]/);
34
+ return parts.map((p) => String(p ?? "").trim()).filter(Boolean);
35
+ }
36
+
37
+ /**
38
+ * Addresses of the app's admin users — the fallback when nothing is configured,
39
+ * so a store that never filled in Settings → Emails is not silent. Resolved at
40
+ * send time (never seeded) so promoting an admin is enough to start receiving
41
+ * order mail. Memoized per isolate: a checkout only pays for it once.
42
+ */
43
+ let adminUsersCache: Promise<string[]> | null = null;
44
+ export function adminUserEmails(sr: any): Promise<string[]> {
45
+ adminUsersCache ??= (async () => {
46
+ try {
47
+ const users = await sr.entities.User.filter({ role: "admin" }, undefined, 50, 0, ["email"]);
48
+ return toAddresses((users ?? []).map((u: any) => u?.email));
49
+ } catch (e) {
50
+ console.error("adminUserEmails failed:", e);
51
+ return [];
52
+ }
53
+ })();
54
+ return adminUsersCache;
55
+ }
56
+
57
+ function dedupeByAddress(list: Recipient[]): Recipient[] {
58
+ const seen = new Set<string>();
59
+ return list.filter(({ to }) => {
60
+ const key = to.toLowerCase();
61
+ if (seen.has(key)) return false;
62
+ seen.add(key);
63
+ return true;
64
+ });
65
+ }
66
+
67
+ export interface SendOrderEmailOpts {
68
+ settings?: Record<string, any>; // pre-fetched groups (emails + general)
69
+ extra?: RenderExtra;
70
+ force?: boolean; // bypass the emails_sent dedupe (manual re-send)
71
+ }
72
+
73
+ /**
74
+ * Send one of the transactional order emails. Returns
75
+ * {sent, reason?} — never throws (email failures must not break checkout).
76
+ */
77
+ export async function sendOrderEmail(
78
+ sr: any,
79
+ type: string,
80
+ order: any,
81
+ opts: SendOrderEmailOpts = {},
82
+ ): Promise<{ sent: boolean; reason?: string }> {
83
+ try {
84
+ const settings = opts.settings ?? (await getSettings(sr, "emails", "general"));
85
+ const cfg = settings.emails ?? {};
86
+ const typeCfg = cfg[type] ?? {};
87
+
88
+ if (typeCfg.enabled === false) return { sent: false, reason: "disabled" };
89
+ if (!opts.force && ONCE_TYPES.has(type) && (order.emails_sent || []).includes(type)) {
90
+ return { sent: false, reason: "already_sent" };
91
+ }
92
+
93
+ const targets: Recipient[] = [];
94
+ if (ADMIN_TYPES.has(type)) {
95
+ // Only an override holding a real address wins — a blank one must not
96
+ // shadow admin_recipients and send to "".
97
+ const perType = toAddresses(typeCfg.recipient);
98
+ const configured = perType.length ? perType : toAddresses(cfg.admin_recipients);
99
+ const admins = configured.length ? configured : await adminUserEmails(sr);
100
+ for (const to of admins) targets.push({ to, target: "admin" });
101
+ }
102
+ if (CUSTOMER_TYPES.has(type)) {
103
+ for (const to of toAddresses(order?.billing?.email)) targets.push({ to, target: "customer" });
104
+ }
105
+
106
+ const recipients = dedupeByAddress(targets);
107
+ if (!recipients.length) {
108
+ const why: string[] = [];
109
+ if (ADMIN_TYPES.has(type)) why.push("no admin recipients configured and no app user has the admin role");
110
+ if (CUSTOMER_TYPES.has(type)) why.push("the order has no billing email");
111
+ await logEmail(sr, {
112
+ type,
113
+ recipient: "",
114
+ target: ADMIN_TYPES.has(type) ? "admin" : "customer",
115
+ order,
116
+ success: false,
117
+ error: `no recipient — ${why.join("; ")}`,
118
+ });
119
+ return { sent: false, reason: "no_recipient" };
120
+ }
121
+
122
+ const { subject, html } = renderOrderEmail(type, order, settings, opts.extra ?? {});
123
+ const fromName = cfg.from_name || settings.general?.store_name || undefined;
124
+
125
+ let anySent = false;
126
+ for (const { to, target } of recipients) {
127
+ try {
128
+ await sr.integrations.Core.SendEmail({ to, subject, body: html, from_name: fromName });
129
+ await logEmail(sr, { type, recipient: to, target, subject, order, success: true });
130
+ anySent = true;
131
+ } catch (e) {
132
+ await logEmail(sr, {
133
+ type, recipient: to, target, subject, order,
134
+ success: false, error: String((e as Error)?.message ?? e),
135
+ });
136
+ }
137
+ }
138
+
139
+ if (anySent && ONCE_TYPES.has(type)) {
140
+ const sent = [...(order.emails_sent || []), type];
141
+ order.emails_sent = sent;
142
+ await sr.entities["commerce.Order"].update(order.id, { emails_sent: sent });
143
+ }
144
+ return { sent: anySent };
145
+ } catch (e) {
146
+ console.error(`sendOrderEmail(${type}) failed:`, e);
147
+ return { sent: false, reason: "error" };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Low/out-of-stock notifications to the inventory recipient
153
+ * (kind: "low_stock" | "out_of_stock" | "backorder").
154
+ */
155
+ export async function sendStockEmail(sr: any, kind: string, product: any, opts: { settings?: Record<string, any> } = {}): Promise<void> {
156
+ try {
157
+ const settings = opts.settings ?? (await getSettings(sr, "inventory", "emails", "general"));
158
+ const inv = settings.inventory ?? {};
159
+ if (kind === "low_stock" && inv.notify_low_stock === false) return;
160
+ if (kind === "out_of_stock" && inv.notify_out_of_stock === false) return;
161
+ const to = toAddresses(inv.notification_recipient)[0]
162
+ ?? toAddresses(settings.emails?.admin_recipients)[0]
163
+ ?? (await adminUserEmails(sr))[0];
164
+ if (!to) {
165
+ await logEmail(sr, {
166
+ type: kind,
167
+ recipient: "",
168
+ target: "admin",
169
+ success: false,
170
+ error: "no recipient — no inventory recipient, no admin recipients, and no app user has the admin role",
171
+ });
172
+ return;
173
+ }
174
+
175
+ const storeName = settings.general?.store_name || "";
176
+ const labels: Record<string, string> = {
177
+ low_stock: "is low in stock",
178
+ out_of_stock: "is out of stock",
179
+ backorder: "is on backorder",
180
+ };
181
+ const subject = `${storeName ? `[${storeName}] ` : ""}Product ${labels[kind] ?? kind}: ${product.name}`;
182
+ const body = `<p><strong>${product.name}</strong> (${product.sku || "no SKU"}) ${labels[kind] ?? kind}.</p>
183
+ <p>Remaining stock: ${product.stock_quantity ?? "n/a"}</p>`;
184
+ try {
185
+ await sr.integrations.Core.SendEmail({ to, subject, body, from_name: settings.emails?.from_name });
186
+ await logEmail(sr, { type: kind, recipient: to, target: "admin", subject, success: true });
187
+ } catch (e) {
188
+ await logEmail(sr, {
189
+ type: kind, recipient: to, target: "admin", subject,
190
+ success: false, error: String((e as Error)?.message ?? e),
191
+ });
192
+ throw e;
193
+ }
194
+ } catch (e) {
195
+ console.error(`sendStockEmail(${kind}) failed:`, e);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Write one EmailLog row. `order_id` is the id users are shown (order_number);
201
+ * the record id goes to `order_record_id`.
202
+ */
203
+ async function logEmail(sr: any, entry: {
204
+ type: string;
205
+ recipient: string;
206
+ target: "admin" | "customer";
207
+ subject?: string;
208
+ order?: any;
209
+ success: boolean;
210
+ error?: string;
211
+ }): Promise<void> {
212
+ try {
213
+ const { order } = entry;
214
+ await sr.entities["commerce.EmailLog"].create({
215
+ email_type: entry.type,
216
+ recipient: entry.recipient,
217
+ target: entry.target,
218
+ subject: entry.subject ?? "",
219
+ order_id: String(order?.order_number ?? order?.id ?? ""),
220
+ order_record_id: order?.id ?? "",
221
+ success: entry.success,
222
+ error: entry.error ?? "",
223
+ });
224
+ } catch { /* logging must never break the caller */ }
225
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Monetary helpers. Every money value in this template flows through round2()
3
+ * so stored totals are always 2-dp numbers (Base44-idiomatic numeric totals).
4
+ */
5
+ import { CURRENCIES } from "./data/currencies.ts";
6
+
7
+ /** Round to 2 decimal places (half-up, EPSILON-guarded against FP drift). */
8
+ export function round2(n: number): number {
9
+ const v = Number(n) || 0;
10
+ return Math.round((v + (v >= 0 ? Number.EPSILON : -Number.EPSILON)) * 100) / 100;
11
+ }
12
+
13
+ /**
14
+ * Distribute `total` across `weights` proportionally and cents-exact using the
15
+ * largest-remainder method, so the shares always sum to exactly round2(total).
16
+ * Used when spreading a fixed_cart coupon across eligible lines.
17
+ */
18
+ export function distributeProportionally(total: number, weights: number[]): number[] {
19
+ const sum = weights.reduce((a, b) => a + (Number(b) || 0), 0);
20
+ const totalCents = Math.round(round2(total) * 100);
21
+ if (sum <= 0 || totalCents <= 0 || !weights.length) return weights.map(() => 0);
22
+ const raw = weights.map((w) => ((Number(w) || 0) / sum) * totalCents);
23
+ const cents = raw.map((r) => Math.floor(r));
24
+ let remainder = totalCents - cents.reduce((a, b) => a + b, 0);
25
+ // hand leftover cents to the largest fractional parts first
26
+ const order = raw
27
+ .map((r, i) => ({ i, frac: r - Math.floor(r) }))
28
+ .sort((a, b) => b.frac - a.frac);
29
+ for (const { i } of order) {
30
+ if (remainder <= 0) break;
31
+ cents[i] += 1;
32
+ remainder -= 1;
33
+ }
34
+ return cents.map((c) => c / 100);
35
+ }
36
+
37
+ /**
38
+ * Format an amount per the store's `general` settings values:
39
+ * {currency, currency_position, thousand_sep, decimal_sep, num_decimals}.
40
+ * Positions: left | right | left_space | right_space.
41
+ */
42
+ export function formatMoney(amount: number, general: Record<string, unknown> = {}): string {
43
+ const code = String(general.currency ?? "USD");
44
+ const currency = CURRENCIES.find((c) => c.code === code);
45
+ const symbol = currency?.symbol ?? code;
46
+ const decimals = Number(general.num_decimals ?? currency?.decimals ?? 2);
47
+ const thousandSep = String(general.thousand_sep ?? ",");
48
+ const decimalSep = String(general.decimal_sep ?? ".");
49
+ const position = String(general.currency_position ?? "left");
50
+
51
+ const negative = amount < 0;
52
+ const fixed = Math.abs(round2(amount)).toFixed(decimals);
53
+ const [intPart, fracPart] = fixed.split(".");
54
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep);
55
+ const num = fracPart ? `${grouped}${decimalSep}${fracPart}` : grouped;
56
+
57
+ let out: string;
58
+ switch (position) {
59
+ case "right": out = `${num}${symbol}`; break;
60
+ case "left_space": out = `${symbol} ${num}`; break;
61
+ case "right_space": out = `${num} ${symbol}`; break;
62
+ case "left":
63
+ default: out = `${symbol}${num}`;
64
+ }
65
+ return negative ? `-${out}` : out;
66
+ }