@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,207 @@
1
+ /**
2
+ * commerce/storefront-account — customer self-service API.
3
+ *
4
+ * Two access modes:
5
+ * - authenticated (base44 auth): my-orders, my-downloads, update-my-addresses, my-reviews
6
+ * - order_key bearer (guest order tracking): get-order, order-notes, get-download
7
+ *
8
+ * All entity access is service-role; the caller is verified per-action
9
+ * (Order/Customer/DownloadPermission entities are admin-only RLS).
10
+ */
11
+ import { createClientFromRequest } from "npm:@base44/sdk";
12
+ import { HttpError, getCallerUser, requireUser } from "../../../shared/commerce/auth.ts";
13
+ import { serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
14
+
15
+ function ok(data: unknown, status = 200): Response {
16
+ return Response.json({ success: true, data }, { status });
17
+ }
18
+
19
+ function fail(e: unknown): Response {
20
+ if (e instanceof HttpError) {
21
+ return Response.json({ success: false, error: e.message, code: e.code ?? "error" }, { status: e.status });
22
+ }
23
+ console.error("commerce/storefront-account error:", e);
24
+ return Response.json(
25
+ { success: false, error: (e as Error)?.message ?? "Internal error", code: "internal_error" },
26
+ { status: 500 },
27
+ );
28
+ }
29
+
30
+ async function customerByEmail(sr: any, email: string): Promise<any | null> {
31
+ return (await sr.entities["commerce.Customer"].filter({ email: String(email).toLowerCase() }, undefined, 1))?.[0] ?? null;
32
+ }
33
+
34
+ async function orderByKey(sr: any, orderId: string, orderKey: string): Promise<any> {
35
+ if (!orderId || !orderKey) throw new HttpError(400, "order_id and order_key are required.", "order_key_required");
36
+ let order: any = null;
37
+ try { order = await sr.entities["commerce.Order"].get(orderId); } catch { order = null; }
38
+ if (!order || order.order_key !== orderKey) throw new HttpError(404, "Order not found.", "order_not_found");
39
+ return order;
40
+ }
41
+
42
+ Deno.serve(async (req: Request) => {
43
+ try {
44
+ const base44 = createClientFromRequest(req);
45
+ const sr = base44.asServiceRole;
46
+ const { action, ...payload } = await req.json().catch(() => ({}));
47
+ if (!action) throw new HttpError(400, "action is required.", "action_required");
48
+ const user = await getCallerUser(base44);
49
+
50
+ switch (action) {
51
+ case "my-orders": {
52
+ requireUser(user);
53
+ const page = Math.max(1, Math.floor(Number(payload.page) || 1));
54
+ const perPage = Math.min(50, Math.max(1, Math.floor(Number(payload.per_page) || 10)));
55
+ const customer = await customerByEmail(sr, user.email);
56
+ if (!customer) return ok({ orders: [], page, per_page: perPage, has_next: false });
57
+ const rows = (await sr.entities["commerce.Order"].filter(
58
+ { customer_id: customer.id },
59
+ "-created_date",
60
+ perPage + 1,
61
+ (page - 1) * perPage,
62
+ )) ?? [];
63
+ return ok({
64
+ orders: rows.slice(0, perPage).map(serializeOrderForCustomer),
65
+ page,
66
+ per_page: perPage,
67
+ has_next: rows.length > perPage,
68
+ });
69
+ }
70
+
71
+ case "get-order": {
72
+ const order = await orderByKey(sr, payload.order_id, payload.order_key);
73
+ return ok({ order: serializeOrderForCustomer(order) });
74
+ }
75
+
76
+ case "order-notes": {
77
+ const order = await orderByKey(sr, payload.order_id, payload.order_key);
78
+ const notes = (await sr.entities["commerce.OrderNote"].filter(
79
+ { order_id: order.id, is_customer_note: true },
80
+ "-created_date",
81
+ 100,
82
+ )) ?? [];
83
+ return ok({ notes: notes.map((n: any) => ({ id: n.id, note: n.note, created_date: n.created_date })) });
84
+ }
85
+
86
+ case "my-downloads": {
87
+ requireUser(user);
88
+ const permissions = (await sr.entities["commerce.DownloadPermission"].filter(
89
+ { customer_email: String(user.email).toLowerCase() },
90
+ "-created_date",
91
+ 200,
92
+ )) ?? [];
93
+ return ok({ downloads: permissions.map(publicPermission) });
94
+ }
95
+
96
+ case "get-download": {
97
+ const permissionId = String(payload.permission_id || "");
98
+ if (!permissionId) throw new HttpError(400, "permission_id is required.", "permission_required");
99
+ let perm: any = null;
100
+ try { perm = await sr.entities["commerce.DownloadPermission"].get(permissionId); } catch { perm = null; }
101
+ if (!perm) throw new HttpError(404, "Download not found.", "download_not_found");
102
+
103
+ // access check: authenticated owner OR order_key bearer
104
+ const authed = user && String(user.email).toLowerCase() === String(perm.customer_email).toLowerCase();
105
+ const keyed = payload.order_key && payload.order_key === perm.order_key;
106
+ if (!authed && !keyed) throw new HttpError(403, "You do not have access to this download.", "forbidden");
107
+
108
+ if (perm.downloads_remaining === 0) {
109
+ throw new HttpError(403, "Download limit reached.", "download_limit_reached");
110
+ }
111
+ if (perm.access_expires && new Date(perm.access_expires).getTime() < Date.now()) {
112
+ throw new HttpError(403, "Download access has expired.", "download_expired");
113
+ }
114
+
115
+ const patch: Record<string, any> = { download_count: (perm.download_count ?? 0) + 1 };
116
+ if (perm.downloads_remaining > 0) patch.downloads_remaining = perm.downloads_remaining - 1;
117
+ await sr.entities["commerce.DownloadPermission"].update(perm.id, patch);
118
+
119
+ // private files (non-http uri) get a short-lived signed URL
120
+ let url = perm.file_url ?? "";
121
+ if (url && !/^https?:\/\//i.test(url)) {
122
+ const signed = await sr.integrations.Core.CreateFileSignedUrl({ file_uri: url, expires_in: 3600 });
123
+ url = signed?.signed_url ?? url;
124
+ }
125
+ return ok({
126
+ url,
127
+ download_name: perm.download_name,
128
+ downloads_remaining: patch.downloads_remaining ?? perm.downloads_remaining,
129
+ });
130
+ }
131
+
132
+ case "update-my-addresses": {
133
+ requireUser(user);
134
+ const email = String(user.email).toLowerCase();
135
+ const existing = await customerByEmail(sr, email);
136
+ const patch: Record<string, any> = {};
137
+ if (payload.billing) patch.billing = payload.billing;
138
+ if (payload.shipping) patch.shipping = payload.shipping;
139
+ if (!Object.keys(patch).length) {
140
+ throw new HttpError(400, "Provide billing and/or shipping.", "nothing_to_update");
141
+ }
142
+ let customer: any;
143
+ if (existing) {
144
+ await sr.entities["commerce.Customer"].update(existing.id, patch);
145
+ customer = { ...existing, ...patch };
146
+ } else {
147
+ customer = await sr.entities["commerce.Customer"].create({
148
+ email,
149
+ first_name: user.full_name?.split(" ")[0] ?? "",
150
+ last_name: user.full_name?.split(" ").slice(1).join(" ") ?? "",
151
+ user_id: user.id,
152
+ is_guest: false,
153
+ ...patch,
154
+ });
155
+ }
156
+ return ok({ customer: publicCustomer(customer) });
157
+ }
158
+
159
+ case "my-reviews": {
160
+ requireUser(user);
161
+ const reviews = (await sr.entities["commerce.ProductReview"].filter(
162
+ { reviewer_email: String(user.email).toLowerCase() },
163
+ "-created_date",
164
+ 100,
165
+ )) ?? [];
166
+ return ok({
167
+ reviews: reviews.map((r: any) => ({
168
+ id: r.id,
169
+ product_id: r.product_id,
170
+ review: r.review,
171
+ rating: r.rating,
172
+ status: r.status,
173
+ verified: !!r.verified,
174
+ created_date: r.created_date,
175
+ })),
176
+ });
177
+ }
178
+
179
+ default:
180
+ throw new HttpError(400, `Unknown action: ${action}`, "unknown_action");
181
+ }
182
+ } catch (e) {
183
+ return fail(e);
184
+ }
185
+ });
186
+
187
+ function publicPermission(p: any): any {
188
+ return {
189
+ permission_id: p.id,
190
+ order_id: p.order_id,
191
+ product_id: p.product_id,
192
+ download_name: p.download_name,
193
+ downloads_remaining: p.downloads_remaining,
194
+ access_expires: p.access_expires,
195
+ download_count: p.download_count ?? 0,
196
+ };
197
+ }
198
+
199
+ function publicCustomer(c: any): any {
200
+ return {
201
+ email: c.email,
202
+ first_name: c.first_name,
203
+ last_name: c.last_name,
204
+ billing: c.billing ?? {},
205
+ shipping: c.shipping ?? {},
206
+ };
207
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Cart resolution + pricing helpers for storefront functions.
3
+ * NOTE: duplicated in commerce/storefront-cart/ and commerce/storefront-checkout/ — keep in sync.
4
+ * (Base44 functions can only share code via base44/shared/commerce/, which this
5
+ * template treats as engine-only; per-function helpers live beside entry.ts.)
6
+ */
7
+ import { HttpError } from "../../../shared/commerce/auth.ts";
8
+ import { getSettings } from "../../../shared/commerce/settings.ts";
9
+ import { calculateTotals } from "../../../shared/commerce/totals.ts";
10
+ import { validateCoupon } from "../../../shared/commerce/coupons.ts";
11
+ import { checkPurchasable } from "../../../shared/commerce/stock.ts";
12
+ import { resolveShippingSelection } from "../../../shared/commerce/shipping.ts";
13
+ import { getSetting } from "../../../shared/commerce/settings.ts";
14
+ import { round2 } from "../../../shared/commerce/money.ts";
15
+ import { scanAll } from "../../../shared/commerce/scan.ts";
16
+
17
+ export const CART_TTL_MS = 48 * 60 * 60 * 1000; // 48h, refreshed on touch
18
+
19
+ export function cartExpiry(): string {
20
+ return new Date(Date.now() + CART_TTL_MS).toISOString();
21
+ }
22
+
23
+ /** Load an active cart by its bearer token. Throws 404 (not found / expired). */
24
+ export async function loadCart(sr: any, cartToken: string | undefined): Promise<any> {
25
+ if (!cartToken) throw new HttpError(400, "cart_token is required.", "cart_token_required");
26
+ const cart = (await sr.entities["commerce.Cart"].filter({ cart_token: cartToken }, undefined, 1))?.[0];
27
+ if (!cart || cart.status !== "active") {
28
+ throw new HttpError(404, "Cart not found.", "cart_not_found");
29
+ }
30
+ if (cart.expires_at && new Date(cart.expires_at).getTime() < Date.now()) {
31
+ try { await sr.entities["commerce.Cart"].update(cart.id, { status: "abandoned" }); } catch { /* best-effort */ }
32
+ throw new HttpError(404, "Cart has expired.", "cart_expired");
33
+ }
34
+ return cart;
35
+ }
36
+
37
+ export interface PricingData {
38
+ settings: Record<string, any>;
39
+ taxRates: any[];
40
+ zones: any[];
41
+ zoneMethods: any[];
42
+ }
43
+
44
+ /** One round of catalog-config reads shared by every priced response. */
45
+ export async function loadPricingData(sr: any): Promise<PricingData> {
46
+ const [settings, taxRates, zones, zoneMethods] = await Promise.all([
47
+ getSettings(sr),
48
+ scanAll(sr.entities["commerce.TaxRate"], {}, "menu_order", 2000),
49
+ scanAll(sr.entities["commerce.ShippingZone"], {}, "order", 500),
50
+ scanAll(sr.entities["commerce.ShippingZoneMethod"], {}, "order", 1000),
51
+ ]);
52
+ return { settings, taxRates, zones, zoneMethods };
53
+ }
54
+
55
+ export interface ResolvedItem {
56
+ item: any; // the stored cart item {item_key, product_id, variation_id, quantity, attributes}
57
+ product: any;
58
+ variation?: any;
59
+ }
60
+
61
+ /**
62
+ * Resolve stored cart items to live products/variations. Items whose product
63
+ * vanished or was unpublished are reported in `removed` (they are dropped from
64
+ * the cart and the shopper is told).
65
+ */
66
+ export async function resolveItems(sr: any, cart: any): Promise<{ resolved: ResolvedItem[]; removed: any[] }> {
67
+ const resolved: ResolvedItem[] = [];
68
+ const removed: any[] = [];
69
+ for (const item of cart.items || []) {
70
+ let product: any = null;
71
+ let variation: any = undefined;
72
+ try { product = item.product_id ? await sr.entities["commerce.Product"].get(item.product_id) : null; } catch { product = null; }
73
+ if (!product || product.status !== "publish") {
74
+ removed.push({ item_key: item.item_key, product_id: item.product_id, reason: "This product is no longer available.", code: "unavailable" });
75
+ continue;
76
+ }
77
+ if (item.variation_id) {
78
+ try { variation = await sr.entities["commerce.ProductVariation"].get(item.variation_id); } catch { variation = undefined; }
79
+ if (!variation || (variation.status && variation.status !== "publish")) {
80
+ removed.push({ item_key: item.item_key, product_id: item.product_id, reason: "This product option is no longer available.", code: "unavailable" });
81
+ continue;
82
+ }
83
+ }
84
+ resolved.push({ item, product, variation });
85
+ }
86
+ return { resolved, removed };
87
+ }
88
+
89
+ /** Lines in the shape coupons.ts validateCoupon()/eligibleLines() expect. */
90
+ export function couponCtxLines(resolved: ResolvedItem[]): any[] {
91
+ return resolved.map(({ item, product, variation }) => {
92
+ const src = variation ?? product;
93
+ const price = Number(src?.price ?? 0);
94
+ return {
95
+ line_id: item.item_key,
96
+ product_id: item.product_id,
97
+ variation_id: item.variation_id ?? "",
98
+ quantity: item.quantity,
99
+ unit_price: price,
100
+ subtotal: round2(price * item.quantity),
101
+ discount: 0,
102
+ product,
103
+ variation,
104
+ };
105
+ });
106
+ }
107
+
108
+ export async function findCoupon(sr: any, code: string): Promise<any | null> {
109
+ if (!code) return null;
110
+ return (await sr.entities["commerce.Coupon"].filter({ code: String(code).toLowerCase().trim() }, undefined, 1))?.[0] ?? null;
111
+ }
112
+
113
+ export interface PricedCart {
114
+ view: any; // customer-facing cart payload
115
+ totals: any; // full TotalsResult
116
+ resolved: ResolvedItem[];
117
+ removed: any[];
118
+ validCoupons: any[];
119
+ couponNotices: any[];
120
+ pricingData: PricingData;
121
+ }
122
+
123
+ /**
124
+ * Produce the priced view of a cart: resolve items, revalidate stored coupons
125
+ * (silently dropping ones that stopped validating, with notices), run the
126
+ * totals engine in preview mode, and enrich items for display.
127
+ * Persists item removals / coupon drops back onto the Cart record.
128
+ */
129
+ export async function priceCart(sr: any, cart: any, opts: { pricingData?: PricingData; customerEmail?: string } = {}): Promise<PricedCart> {
130
+ const pricingData = opts.pricingData ?? (await loadPricingData(sr));
131
+ const { settings } = pricingData;
132
+ const { resolved, removed } = await resolveItems(sr, cart);
133
+
134
+ const cartPatch: Record<string, any> = {};
135
+ if (removed.length) {
136
+ const removedKeys = new Set(removed.map((r) => r.item_key));
137
+ cart.items = (cart.items || []).filter((i: any) => !removedKeys.has(i.item_key));
138
+ cartPatch.items = cart.items;
139
+ }
140
+
141
+ // revalidate stored coupons in application order
142
+ const ctxLines = couponCtxLines(resolved);
143
+ const itemsSubtotal = round2(ctxLines.reduce((a, l) => a + l.subtotal, 0));
144
+ const customerEmail = cart.customer_email || opts.customerEmail || "";
145
+ const validCoupons: any[] = [];
146
+ const keptCodes: string[] = [];
147
+ const couponNotices: any[] = [];
148
+ for (const code of cart.coupon_codes || []) {
149
+ const coupon = await findCoupon(sr, code);
150
+ const res = validateCoupon(coupon, {
151
+ lines: ctxLines,
152
+ itemsSubtotal,
153
+ customerEmail,
154
+ appliedCoupons: validCoupons,
155
+ });
156
+ if (res.valid) {
157
+ validCoupons.push(coupon);
158
+ keptCodes.push(code);
159
+ } else {
160
+ couponNotices.push({ code, error: res.error, error_code: res.code });
161
+ }
162
+ }
163
+ if (keptCodes.length !== (cart.coupon_codes || []).length) {
164
+ cart.coupon_codes = keptCodes;
165
+ cartPatch.coupon_codes = keptCodes;
166
+ }
167
+ if (Object.keys(cartPatch).length) {
168
+ try { await sr.entities["commerce.Cart"].update(cart.id, cartPatch); } catch { /* view still correct */ }
169
+ }
170
+
171
+ const priceWith = (chosenShippingMethodId?: string) => calculateTotals({
172
+ items: resolved.map((r) => ({
173
+ product: r.product,
174
+ variation: r.variation,
175
+ quantity: r.item.quantity,
176
+ attributes: r.item.attributes ?? [],
177
+ })),
178
+ coupons: validCoupons,
179
+ shipping_address: cart.shipping_address,
180
+ chosenShippingMethodId: chosenShippingMethodId || undefined,
181
+ settings,
182
+ taxRates: pricingData.taxRates,
183
+ zones: pricingData.zones,
184
+ zoneMethods: pricingData.zoneMethods,
185
+ });
186
+
187
+ let totals = priceWith(cart.chosen_shipping_method);
188
+
189
+ // Keep the cart's shipping choice honest: drop one that is no longer offered
190
+ // (the address or the cart changed) and auto-select when there is exactly one
191
+ // option, so a storefront that only sets an address still gets a shipping
192
+ // line instead of silently shipping for free. `shipping_status` on the view
193
+ // tells the UI whether it still has to ask the customer.
194
+ const shippingSelection = resolveShippingSelection({
195
+ needsShipping: resolved.some((r) => !(r.variation?.virtual ?? r.product.virtual)),
196
+ shippingEnabled: getSetting(settings, "shipping", "enable_shipping", true) !== false,
197
+ chosenMethodId: cart.chosen_shipping_method,
198
+ available: totals.available_shipping_methods,
199
+ });
200
+ if (shippingSelection.method_id !== (cart.chosen_shipping_method || "")) {
201
+ cart.chosen_shipping_method = shippingSelection.method_id;
202
+ try {
203
+ await sr.entities["commerce.Cart"].update(cart.id, { chosen_shipping_method: shippingSelection.method_id });
204
+ } catch { /* view still correct */ }
205
+ totals = priceWith(shippingSelection.method_id);
206
+ }
207
+
208
+ // display items: engine lines align by index with `resolved`
209
+ const items = resolved.map((r, i) => {
210
+ const priced = totals.line_items[i] ?? {};
211
+ const src = r.variation ?? r.product;
212
+ return {
213
+ item_key: r.item.item_key,
214
+ product_id: r.item.product_id,
215
+ variation_id: r.item.variation_id ?? "",
216
+ quantity: r.item.quantity,
217
+ attributes: r.item.attributes ?? [],
218
+ name: r.product.name ?? "",
219
+ sku: priced.sku ?? "",
220
+ image: r.variation?.image?.src || r.product.images?.[0]?.src || "",
221
+ price: priced.price ?? Number(src?.price ?? 0),
222
+ subtotal: priced.subtotal ?? 0,
223
+ total: priced.total ?? 0,
224
+ total_tax: priced.total_tax ?? 0,
225
+ virtual: !!(r.variation?.virtual ?? r.product.virtual),
226
+ sold_individually: !!r.product.sold_individually,
227
+ purchasable: checkPurchasable(r.product, r.variation, r.item.quantity, settings),
228
+ };
229
+ });
230
+
231
+ const view = {
232
+ cart_token: cart.cart_token,
233
+ items,
234
+ coupon_codes: cart.coupon_codes ?? [],
235
+ coupons: totals.coupon_lines,
236
+ coupon_notices: couponNotices,
237
+ removed_items: removed,
238
+ shipping_address: cart.shipping_address ?? null,
239
+ chosen_shipping_method: cart.chosen_shipping_method ?? "",
240
+ available_shipping_methods: totals.available_shipping_methods,
241
+ shipping_status: shippingSelection.state,
242
+ totals: {
243
+ subtotal: totals.subtotal,
244
+ discount_total: totals.discount_total,
245
+ discount_tax: totals.discount_tax,
246
+ shipping_total: totals.shipping_total,
247
+ shipping_tax: totals.shipping_tax,
248
+ cart_tax: totals.cart_tax,
249
+ total_tax: totals.total_tax,
250
+ total: totals.total,
251
+ prices_include_tax: totals.prices_include_tax,
252
+ tax_lines: totals.tax_lines,
253
+ },
254
+ expires_at: cart.expires_at ?? null,
255
+ };
256
+
257
+ return { view, totals, resolved, removed, validCoupons, couponNotices, pricingData };
258
+ }