@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,314 @@
1
+ /**
2
+ * The order totals engine — the single source of truth for pricing math.
3
+ * Used by: commerce/storefront-cart `totals` (preview), commerce/storefront-checkout
4
+ * `place-order` (authoritative), commerce/admin-orders `create`/`recalculate`.
5
+ *
6
+ * Pipeline (standard commerce semantics): resolve lines → coupons → fees → shipping → tax → totals.
7
+ * All internal math is ex-tax; tax-inclusive catalog prices are netted first.
8
+ */
9
+ import { round2, distributeProportionally } from "./money.ts";
10
+ import { applyRates, extractInclusiveTax, matchTaxRates, sumTax } from "./tax.ts";
11
+ import { availableMethods, matchZone } from "./shipping.ts";
12
+ import { applyCoupons } from "./coupons.ts";
13
+ import { getSetting } from "./settings.ts";
14
+ import { uuid } from "./sequence.ts";
15
+
16
+ export interface TotalsItem {
17
+ product: any;
18
+ variation?: any;
19
+ quantity: number;
20
+ attributes?: Array<{ name: string; option: string }>;
21
+ meta_data?: Array<{ key: string; value: string }>;
22
+ }
23
+
24
+ export interface TotalsFee {
25
+ name: string;
26
+ amount: number;
27
+ tax_class?: string;
28
+ tax_status?: string; // taxable | none
29
+ line_id?: string;
30
+ }
31
+
32
+ export interface TotalsInput {
33
+ items: TotalsItem[];
34
+ coupons?: any[]; // validated Coupon records, in application order
35
+ fees?: TotalsFee[];
36
+ billing?: any;
37
+ shipping_address?: any;
38
+ chosenShippingMethodId?: string; // ShippingZoneMethod id
39
+ settings: Record<string, any>; // groups object from getSettings()
40
+ taxRates?: any[];
41
+ zones?: any[];
42
+ zoneMethods?: any[];
43
+ }
44
+
45
+ export interface TotalsResult {
46
+ line_items: any[];
47
+ shipping_lines: any[];
48
+ tax_lines: any[];
49
+ fee_lines: any[];
50
+ coupon_lines: any[];
51
+ subtotal: number;
52
+ discount_total: number;
53
+ discount_tax: number;
54
+ shipping_total: number;
55
+ shipping_tax: number;
56
+ cart_tax: number;
57
+ total_tax: number;
58
+ total: number;
59
+ prices_include_tax: boolean;
60
+ available_shipping_methods: Array<{ id: string; method_id: string; title: string; cost: number }>;
61
+ matched_zone_id: string | null;
62
+ }
63
+
64
+ /** Effective tax class/status with variation "parent" inheritance. */
65
+ function effectiveTax(product: any, variation?: any): { tax_class: string; tax_status: string } {
66
+ const status = variation?.tax_status && variation.tax_status !== "parent"
67
+ ? variation.tax_status
68
+ : product.tax_status ?? "taxable";
69
+ const cls = (variation?.tax_class || product.tax_class) ?? "standard";
70
+ return { tax_class: cls, tax_status: status };
71
+ }
72
+
73
+ function resolveTaxAddress(input: TotalsInput): any {
74
+ const basedOn = getSetting(input.settings, "tax", "tax_based_on", "shipping");
75
+ if (basedOn === "billing") return input.billing ?? input.shipping_address ?? {};
76
+ if (basedOn === "base") return getSetting(input.settings, "general", "address", {});
77
+ return input.shipping_address ?? input.billing ?? {}; // "shipping" default
78
+ }
79
+
80
+ /** Run the full totals pipeline. Pure aside from reading its inputs. */
81
+ export function calculateTotals(input: TotalsInput): TotalsResult {
82
+ const settings = input.settings ?? {};
83
+ const taxesEnabled = !!getSetting(settings, "general", "enable_taxes", true);
84
+ const pricesIncludeTax = taxesEnabled && !!getSetting(settings, "tax", "prices_include_tax", false);
85
+ const taxRates = input.taxRates ?? [];
86
+ const taxAddress = resolveTaxAddress(input);
87
+
88
+ // ── 1. resolve lines (ex-tax) ────────────────────────────────────────────
89
+ const lines = (input.items || []).map((it) => {
90
+ const src = it.variation ?? it.product;
91
+ const { tax_class, tax_status } = effectiveTax(it.product, it.variation);
92
+ const rates = taxesEnabled && tax_status === "taxable" ? matchTaxRates(taxRates, taxAddress, tax_class) : [];
93
+ let unitPrice = Number(src.price ?? it.product?.price ?? 0);
94
+ let subtotal = round2(unitPrice * it.quantity);
95
+ if (pricesIncludeTax && rates.length) {
96
+ const { net } = extractInclusiveTax(subtotal, rates);
97
+ subtotal = net;
98
+ unitPrice = it.quantity ? net / it.quantity : 0;
99
+ }
100
+ return {
101
+ line_id: uuid(),
102
+ product_id: it.product?.id ?? "",
103
+ variation_id: it.variation?.id ?? "",
104
+ name: it.product?.name ?? "",
105
+ sku: (it.variation?.sku || it.product?.sku) ?? "",
106
+ quantity: it.quantity,
107
+ unit_price: unitPrice,
108
+ tax_class,
109
+ tax_status,
110
+ rates,
111
+ subtotal,
112
+ discount: 0,
113
+ total: subtotal,
114
+ subtotal_tax: 0,
115
+ total_tax: 0,
116
+ taxes: [] as any[],
117
+ attributes: it.attributes ?? [],
118
+ meta_data: it.meta_data ?? [],
119
+ shipping_class_id: (it.variation?.shipping_class_id || it.product?.shipping_class_id) ?? "",
120
+ virtual: !!(it.variation?.virtual ?? it.product?.virtual),
121
+ product: it.product,
122
+ variation: it.variation,
123
+ };
124
+ });
125
+
126
+ const itemsSubtotal = round2(lines.reduce((a, l) => a + l.subtotal, 0));
127
+
128
+ // ── 2. coupons ───────────────────────────────────────────────────────────
129
+ const couponsEnabled = !!getSetting(settings, "general", "enable_coupons", true);
130
+ const coupons = couponsEnabled ? input.coupons ?? [] : [];
131
+ const couponLines = applyCoupons(lines, coupons, settings);
132
+ for (const l of lines) l.total = round2(l.subtotal - l.discount);
133
+ const discountTotal = round2(lines.reduce((a, l) => a + l.discount, 0));
134
+ const itemsAfterDiscount = round2(lines.reduce((a, l) => a + l.total, 0));
135
+
136
+ // ── 3. fees ──────────────────────────────────────────────────────────────
137
+ const feeLines = (input.fees || []).map((f) => ({
138
+ line_id: f.line_id ?? uuid(),
139
+ name: f.name,
140
+ tax_class: f.tax_class ?? "standard",
141
+ tax_status: f.tax_status ?? "taxable",
142
+ total: round2(Number(f.amount) || 0),
143
+ total_tax: 0,
144
+ taxes: [] as any[],
145
+ }));
146
+
147
+ // ── 4. shipping ──────────────────────────────────────────────────────────
148
+ const shippingEnabled = getSetting(settings, "shipping", "enable_shipping", true) !== false;
149
+ const needsShipping = lines.some((l) => !l.virtual);
150
+ const shippingLines: any[] = [];
151
+ let available: Array<{ id: string; method_id: string; title: string; cost: number }> = [];
152
+ let matchedZoneId: string | null = null;
153
+
154
+ if (shippingEnabled && needsShipping && input.shipping_address?.country) {
155
+ const zone = matchZone(input.zones ?? [], input.shipping_address);
156
+ if (zone) {
157
+ matchedZoneId = zone.id ?? null;
158
+ const cart = { lines, itemsSubtotal, itemsSubtotalAfterDiscount: itemsAfterDiscount };
159
+ const offers = availableMethods(zone, input.zoneMethods ?? [], cart, settings, coupons);
160
+ available = offers.map((o) => ({
161
+ id: o.method.id,
162
+ method_id: o.method.method_id,
163
+ title: o.method.title || defaultMethodTitle(o.method.method_id),
164
+ cost: o.cost,
165
+ }));
166
+ if (input.chosenShippingMethodId) {
167
+ const chosen = offers.find((o) => o.method.id === input.chosenShippingMethodId);
168
+ if (chosen) {
169
+ shippingLines.push({
170
+ line_id: uuid(),
171
+ method_id: chosen.method.method_id,
172
+ instance_id: chosen.method.id,
173
+ method_title: chosen.method.title || defaultMethodTitle(chosen.method.method_id),
174
+ total: chosen.cost,
175
+ total_tax: 0,
176
+ taxes: [] as any[],
177
+ });
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ // ── 5. tax ───────────────────────────────────────────────────────────────
184
+ // aggregated per rate for order-level tax_lines
185
+ const agg = new Map<string, { rate: any; tax_total: number; shipping_tax_total: number }>();
186
+ const bump = (rate: any, tax: number, shippingTax: number) => {
187
+ const key = rate.id ?? `${rate.name}-${rate.priority}`;
188
+ const cur = agg.get(key) ?? { rate, tax_total: 0, shipping_tax_total: 0 };
189
+ cur.tax_total = round2(cur.tax_total + tax);
190
+ cur.shipping_tax_total = round2(cur.shipping_tax_total + shippingTax);
191
+ agg.set(key, cur);
192
+ };
193
+
194
+ if (taxesEnabled) {
195
+ for (const l of lines) {
196
+ if (!l.rates.length) continue;
197
+ const subApplied = applyRates(l.subtotal, l.rates);
198
+ const totApplied = applyRates(l.total, l.rates);
199
+ l.subtotal_tax = sumTax(subApplied);
200
+ l.total_tax = sumTax(totApplied);
201
+ l.taxes = totApplied.map((t, i) => ({
202
+ rate_id: t.rate.id ?? "",
203
+ total: t.amount,
204
+ subtotal: subApplied[i]?.amount ?? 0,
205
+ }));
206
+ for (const t of totApplied) bump(t.rate, t.amount, 0);
207
+ }
208
+ for (const fee of feeLines) {
209
+ if (fee.tax_status !== "taxable") continue;
210
+ const rates = matchTaxRates(taxRates, taxAddress, fee.tax_class);
211
+ const applied = applyRates(fee.total, rates);
212
+ fee.total_tax = sumTax(applied);
213
+ fee.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
214
+ for (const t of applied) bump(t.rate, t.amount, 0);
215
+ }
216
+ if (shippingLines.length) {
217
+ // shipping_tax_class "inherit" = first taxable line's class (intended behavior)
218
+ const cfg = getSetting(settings, "tax", "shipping_tax_class", "inherit");
219
+ const cls = cfg === "inherit"
220
+ ? (lines.find((l) => l.tax_status === "taxable")?.tax_class ?? "standard")
221
+ : cfg;
222
+ const rates = matchTaxRates(taxRates, taxAddress, cls).filter((r) => r.shipping !== false);
223
+ for (const sl of shippingLines) {
224
+ const applied = applyRates(sl.total, rates);
225
+ sl.total_tax = sumTax(applied);
226
+ sl.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
227
+ for (const t of applied) bump(t.rate, 0, t.amount);
228
+ }
229
+ }
230
+ }
231
+
232
+ // ── 6. totals ────────────────────────────────────────────────────────────
233
+ const cartTax = round2(
234
+ lines.reduce((a, l) => a + l.total_tax, 0) + feeLines.reduce((a, f) => a + f.total_tax, 0),
235
+ );
236
+ const shippingTotal = round2(shippingLines.reduce((a, s) => a + s.total, 0));
237
+ const shippingTax = round2(shippingLines.reduce((a, s) => a + s.total_tax, 0));
238
+ const totalTax = round2(cartTax + shippingTax);
239
+ const feesTotal = round2(feeLines.reduce((a, f) => a + f.total, 0));
240
+
241
+ // discount tax = tax saved by the discounts (subtotal tax − total tax per line),
242
+ // allocated across coupon lines proportionally to their discounts
243
+ const discountTax = round2(lines.reduce((a, l) => a + (l.subtotal_tax - l.total_tax), 0));
244
+ if (discountTax > 0 && couponLines.length) {
245
+ const shares = distributeProportionally(discountTax, couponLines.map((c) => c.discount));
246
+ couponLines.forEach((c, i) => { c.discount_tax = shares[i] ?? 0; });
247
+ }
248
+
249
+ const total = round2(itemsSubtotal - discountTotal + feesTotal + shippingTotal + totalTax);
250
+
251
+ const taxLines = [...agg.values()].map(({ rate, tax_total, shipping_tax_total }) => ({
252
+ rate_id: rate.id ?? "",
253
+ rate_code: buildRateCode(rate),
254
+ label: rate.name ?? "Tax",
255
+ rate_percent: Number(rate.rate) || 0,
256
+ compound: !!rate.compound,
257
+ tax_total,
258
+ shipping_tax_total,
259
+ }));
260
+
261
+ return {
262
+ line_items: lines.map(stripLine),
263
+ shipping_lines: shippingLines,
264
+ tax_lines: taxLines,
265
+ fee_lines: feeLines,
266
+ coupon_lines: couponLines,
267
+ subtotal: itemsSubtotal,
268
+ discount_total: discountTotal,
269
+ discount_tax: discountTax,
270
+ shipping_total: shippingTotal,
271
+ shipping_tax: shippingTax,
272
+ cart_tax: cartTax,
273
+ total_tax: totalTax,
274
+ total,
275
+ prices_include_tax: pricesIncludeTax,
276
+ available_shipping_methods: available,
277
+ matched_zone_id: matchedZoneId,
278
+ };
279
+ }
280
+
281
+ /** Convert an engine line to the embedded Order.line_items shape. */
282
+ function stripLine(l: any): any {
283
+ return {
284
+ line_id: l.line_id,
285
+ product_id: l.product_id,
286
+ variation_id: l.variation_id,
287
+ name: l.name,
288
+ sku: l.sku,
289
+ quantity: l.quantity,
290
+ price: l.quantity ? round2(l.total / l.quantity) : 0, // post-discount ex-tax unit price
291
+ tax_class: l.tax_class,
292
+ subtotal: l.subtotal,
293
+ subtotal_tax: l.subtotal_tax,
294
+ total: l.total,
295
+ total_tax: l.total_tax,
296
+ taxes: l.taxes,
297
+ attributes: l.attributes,
298
+ meta_data: l.meta_data,
299
+ };
300
+ }
301
+
302
+ function buildRateCode(rate: any): string {
303
+ const parts = [rate.country || "ANY", rate.state || "", (rate.name || "TAX").replace(/\s+/g, "-"), String(rate.priority ?? 1)];
304
+ return parts.filter(Boolean).join("-").toUpperCase();
305
+ }
306
+
307
+ function defaultMethodTitle(methodId: string): string {
308
+ switch (methodId) {
309
+ case "flat_rate": return "Flat rate";
310
+ case "free_shipping": return "Free shipping";
311
+ case "local_pickup": return "Local pickup";
312
+ default: return methodId;
313
+ }
314
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Webhook dispatch (standard webhook headers + HMAC signature).
3
+ * Fire-and-forget safe: every failure is swallowed and logged — a broken
4
+ * webhook endpoint must never break an order flow.
5
+ */
6
+
7
+ const MAX_BODY = 32 * 1024; // truncate logged request/response bodies at 32KB
8
+ const TIMEOUT_MS = 10_000;
9
+ const AUTO_DISABLE_AFTER = 5; // consecutive failures
10
+
11
+ async function hmacSha256Base64(secret: string, payload: string): Promise<string> {
12
+ const key = await crypto.subtle.importKey(
13
+ "raw",
14
+ new TextEncoder().encode(secret),
15
+ { name: "HMAC", hash: "SHA-256" },
16
+ false,
17
+ ["sign"],
18
+ );
19
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
20
+ let bin = "";
21
+ for (const b of new Uint8Array(sig)) bin += String.fromCharCode(b);
22
+ return btoa(bin);
23
+ }
24
+
25
+ /**
26
+ * Deliver `payload` to every active webhook subscribed to `topic`
27
+ * (e.g. "order.created"). Logs each attempt as a WebhookDelivery, maintains
28
+ * failure_count, and auto-disables a hook after 5 consecutive failures.
29
+ */
30
+ export async function dispatch(sr: any, topic: string, payload: unknown): Promise<void> {
31
+ try {
32
+ const hooks = (await sr.entities["commerce.Webhook"].filter({ status: "active", topic })) ?? [];
33
+ for (const hook of hooks) {
34
+ await deliverOne(sr, hook, topic, payload);
35
+ }
36
+ } catch (e) {
37
+ console.error(`webhook dispatch(${topic}) failed:`, e);
38
+ }
39
+ }
40
+
41
+ /** Deliver to a single webhook record; used by dispatch and commerce/admin-webhooks test/redeliver. */
42
+ export async function deliverOne(sr: any, hook: any, topic: string, payload: unknown): Promise<any> {
43
+ const [resource, event] = topic.split(".");
44
+ const body = JSON.stringify(payload ?? {});
45
+ const started = Date.now();
46
+
47
+ // create the delivery record first so its id can ride in the header
48
+ let delivery: any = null;
49
+ try {
50
+ delivery = await sr.entities["commerce.WebhookDelivery"].create({
51
+ webhook_id: hook.id,
52
+ topic,
53
+ delivery_url: hook.delivery_url,
54
+ request_headers: {},
55
+ request_body: body.slice(0, MAX_BODY),
56
+ response_code: 0,
57
+ response_body: "",
58
+ duration_ms: 0,
59
+ success: false,
60
+ });
61
+ } catch (e) {
62
+ console.error("webhook delivery log create failed:", e);
63
+ }
64
+
65
+ const headers: Record<string, string> = {
66
+ "Content-Type": "application/json",
67
+ "X-Commerce-Webhook-Topic": topic,
68
+ "X-Commerce-Webhook-Resource": resource ?? "",
69
+ "X-Commerce-Webhook-Event": event ?? "",
70
+ "X-Commerce-Webhook-ID": hook.id ?? "",
71
+ "X-Commerce-Webhook-Delivery-ID": delivery?.id ?? "",
72
+ };
73
+ if (hook.secret) {
74
+ try {
75
+ headers["X-Commerce-Webhook-Signature"] = await hmacSha256Base64(hook.secret, body);
76
+ } catch { /* signature best-effort */ }
77
+ }
78
+
79
+ let responseCode = 0;
80
+ let responseBody = "";
81
+ let success = false;
82
+ try {
83
+ const res = await fetch(hook.delivery_url, {
84
+ method: "POST",
85
+ headers,
86
+ body,
87
+ signal: AbortSignal.timeout(TIMEOUT_MS),
88
+ });
89
+ responseCode = res.status;
90
+ responseBody = (await res.text()).slice(0, MAX_BODY);
91
+ success = res.ok;
92
+ } catch (e) {
93
+ responseBody = String((e as Error)?.message ?? e).slice(0, MAX_BODY);
94
+ }
95
+
96
+ const duration = Date.now() - started;
97
+ try {
98
+ if (delivery) {
99
+ await sr.entities["commerce.WebhookDelivery"].update(delivery.id, {
100
+ request_headers: headers,
101
+ response_code: responseCode,
102
+ response_body: responseBody,
103
+ duration_ms: duration,
104
+ success,
105
+ });
106
+ }
107
+ const failureCount = success ? 0 : (hook.failure_count ?? 0) + 1;
108
+ const patch: any = { failure_count: failureCount };
109
+ if (!success && failureCount >= AUTO_DISABLE_AFTER) patch.status = "disabled";
110
+ await sr.entities["commerce.Webhook"].update(hook.id, patch);
111
+ } catch (e) {
112
+ console.error("webhook delivery bookkeeping failed:", e);
113
+ }
114
+
115
+ return { delivery_id: delivery?.id ?? "", response_code: responseCode, success, duration_ms: duration };
116
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@base44/app-plugin-commerce",
3
+ "version": "0.1.0",
4
+ "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
+ "keywords": [
6
+ "base44",
7
+ "base44-plugin",
8
+ "commerce",
9
+ "ecommerce",
10
+ "store",
11
+ "template"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/base44/app-plugin-commerce.git"
17
+ },
18
+ "homepage": "https://github.com/base44/app-plugin-commerce#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/base44/app-plugin-commerce/issues"
21
+ },
22
+ "files": [
23
+ "base44",
24
+ "scripts",
25
+ "skills",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "engines": {
35
+ "node": ">=20.19.0"
36
+ }
37
+ }
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Base44 Commerce Template — static installer.
4
+ *
5
+ * Assumes this entire template repository has been copied into the target
6
+ * Base44 app at `<app-root>/examples/commerce/`, so this script runs from
7
+ * `<app-root>/examples/commerce/scripts/`. From the app root:
8
+ *
9
+ * node examples/commerce/scripts/install.js
10
+ *
11
+ * It copies the template's static file set into the app. Paths are relative
12
+ * to this script's folder — the template root is `..` and the app root is
13
+ * `../../..` (scripts → commerce → examples → app root):
14
+ *
15
+ * ../base44/entities/commerce.*.jsonc → ../../../base44/entities/
16
+ * ../base44/functions/commerce/ → ../../../base44/functions/commerce/
17
+ * ../base44/shared/commerce/ → ../../../base44/shared/commerce/
18
+ * ../base44/agents/commerce/ → ../../../base44/agents/commerce/
19
+ * ../src/commerce/admin/ → ../../../src/commerce/admin/
20
+ * ../src/commerce/utils/ → ../../../src/commerce/utils/
21
+ * ../skills/commerce/ → ../../../skills/commerce/
22
+ *
23
+ * Merge semantics: directories are merged — files owned by the template are
24
+ * overwritten (so re-running after a template update is safe), everything
25
+ * else in the app is left untouched.
26
+ *
27
+ * This is only the static part of the install. The remaining steps live in
28
+ * ../skills/commerce/installation-guidelines.md (deps, deploy, seeding) and
29
+ * ../skills/commerce/post-installation.md (mounting the /admin route, admin
30
+ * role, AGENTS.md registration); day-2 guidance starts at
31
+ * ../skills/commerce/SKILL.md. The skill folder carries all of this
32
+ * documentation into the app.
33
+ *
34
+ * Written with dynamic import() and process.argv[1] (instead of require/
35
+ * __dirname) so it runs unchanged whether the host app's package.json is
36
+ * CommonJS or "type": "module".
37
+ */
38
+
39
+ (async () => {
40
+ const fs = await import("node:fs");
41
+ const path = await import("node:path");
42
+
43
+ const fail = (msg) => {
44
+ console.error(`install.js: ${msg}`);
45
+ process.exit(1);
46
+ };
47
+
48
+ const scriptArg = process.argv[1];
49
+ if (!scriptArg) fail("cannot determine the script location (run as: node examples/commerce/scripts/install.js)");
50
+ const scriptDir = path.dirname(path.resolve(scriptArg));
51
+ const templateRoot = path.resolve(scriptDir, ".."); // <app>/examples/commerce
52
+ const appRoot = path.resolve(scriptDir, "..", "..", ".."); // <app>
53
+
54
+ // ── sanity checks ──────────────────────────────────────────────────────────
55
+ const entitiesSrc = path.join(templateRoot, "base44", "entities");
56
+ if (!fs.existsSync(entitiesSrc)) {
57
+ fail(`template assets not found at ${path.join(templateRoot, "base44")} — is the repo intact?`);
58
+ }
59
+ if (!fs.existsSync(path.join(appRoot, "package.json"))) {
60
+ fail(
61
+ `no package.json at ${appRoot} — expected the template at <app-root>/examples/commerce/ ` +
62
+ `so that the app root is three levels above this script's folder.`
63
+ );
64
+ }
65
+ const relToApp = path.relative(appRoot, templateRoot).split(path.sep).join("/");
66
+ if (relToApp !== "examples/commerce") {
67
+ console.warn(
68
+ `install.js: warning — template is installed at "${relToApp}" instead of "examples/commerce"; ` +
69
+ `proceeding, but doc references assume examples/commerce.`
70
+ );
71
+ }
72
+
73
+ // ── copy helpers (merge: overwrite template-owned files, keep the rest) ───
74
+ let filesCopied = 0;
75
+ const copyFile = (src, dest) => {
76
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
77
+ fs.copyFileSync(src, dest);
78
+ filesCopied += 1;
79
+ };
80
+ const copyDir = (src, dest) => {
81
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
82
+ const s = path.join(src, entry.name);
83
+ const d = path.join(dest, entry.name);
84
+ if (entry.isDirectory()) copyDir(s, d);
85
+ else copyFile(s, d);
86
+ }
87
+ };
88
+
89
+ // ── retired files ─────────────────────────────────────────────────────────
90
+ // Copying merges, so a template file that gets renamed would otherwise linger
91
+ // in the app forever — and stale guidance is worse than none, since agents
92
+ // read whatever is in skills/. Anything this template used to own and no
93
+ // longer ships is listed here and removed on install.
94
+ const RETIRED = [
95
+ ["skills", "commerce", "references", "stripe-payments.md"], // → references/online-payments.md
96
+ ];
97
+ let retiredRemoved = 0;
98
+ for (const parts of RETIRED) {
99
+ const target = path.join(appRoot, ...parts);
100
+ if (!fs.existsSync(target)) continue;
101
+ try {
102
+ fs.rmSync(target, { recursive: true, force: true });
103
+ retiredRemoved += 1;
104
+ console.log(`✓ removed ${parts.join("/")} (retired by a newer template version)`);
105
+ } catch (e) {
106
+ console.warn(`install.js: warning — could not remove retired ${parts.join("/")}: ${e.message}`);
107
+ }
108
+ }
109
+
110
+ // ── the static installation ───────────────────────────────────────────────
111
+ const entityFiles = fs
112
+ .readdirSync(entitiesSrc)
113
+ .filter((n) => n.startsWith("commerce.") && n.endsWith(".jsonc"));
114
+ if (entityFiles.length === 0) fail(`no commerce.*.jsonc entity schemas found in ${entitiesSrc}`);
115
+ for (const name of entityFiles) {
116
+ copyFile(path.join(entitiesSrc, name), path.join(appRoot, "base44", "entities", name));
117
+ }
118
+ console.log(`✓ entities ${entityFiles.length} commerce.*.jsonc schemas → base44/entities/`);
119
+
120
+ const dirJobs = [
121
+ { label: "functions", from: ["base44", "functions", "commerce"], to: ["base44", "functions", "commerce"] },
122
+ { label: "shared", from: ["base44", "shared", "commerce"], to: ["base44", "shared", "commerce"] },
123
+ // StoreAdmin agent — registered as "commerce/StoreAdmin" (folder = namespace).
124
+ { label: "agents", from: ["base44", "agents", "commerce"], to: ["base44", "agents", "commerce"] },
125
+ { label: "admin UI", from: ["src", "commerce", "admin"], to: ["src", "commerce", "admin"] },
126
+ // Storefront helpers (variant selection) — framework-free, for the
127
+ // customer-facing UI the app builds itself.
128
+ { label: "utils", from: ["src", "commerce", "utils"], to: ["src", "commerce", "utils"] },
129
+ // Commerce skill — SKILL.md, install/post-install guides, references/
130
+ // and docs/, the guidance agents read before working on the store.
131
+ { label: "skills", from: ["skills", "commerce"], to: ["skills", "commerce"] },
132
+ ];
133
+ for (const job of dirJobs) {
134
+ const src = path.join(templateRoot, ...job.from);
135
+ if (!fs.existsSync(src)) fail(`missing template directory: ${src}`);
136
+ const before = filesCopied;
137
+ copyDir(src, path.join(appRoot, ...job.to));
138
+ console.log(`✓ ${job.label.padEnd(10)} ${filesCopied - before} files → ${job.to.join("/")}/`);
139
+ }
140
+
141
+ console.log(
142
+ `\nDone — ${filesCopied} files installed into ${appRoot}` +
143
+ (retiredRemoved ? ` (${retiredRemoved} retired file${retiredRemoved === 1 ? "" : "s"} removed)` : ""),
144
+ );
145
+ console.log(
146
+ "\nNext steps (see skills/commerce/installation-guidelines.md + post-installation.md):\n" +
147
+ " 1. No deps to add: sonner, recharts and react-markdown ship with the default\n" +
148
+ " Base44 template — just confirm they're there (npm i sonner recharts if not)\n" +
149
+ ' 2. Mount the admin router: <Route path="/admin/*" element={<AdminApp />} />\n' +
150
+ " and implement the payment return page /order-received (post-installation.md)\n" +
151
+ " 3. Grant your user the admin role, then settle the store's data — generate a catalog,\n" +
152
+ " seed the demo data, or initialize defaults from /admin (post-installation.md §2)\n" +
153
+ " 4. Register the template + skill in AGENTS.md (see skills/commerce/post-installation.md)\n" +
154
+ " 5. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
155
+ );
156
+ })();