@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,485 @@
1
+ /**
2
+ * commerce/storefront-checkout — public checkout API.
3
+ *
4
+ * Actions:
5
+ * place-order — the 9-step checkout (validate → totals → customer upsert
6
+ * → order create pending → stock reduce → gateway routing)
7
+ * confirm-payment — post-payment hook (pending/on-hold → processing), confirmed
8
+ * against the provider. commerce/payments `complete-return`
9
+ * is the fuller version.
10
+ * cancel-order — customer-initiated cancel (pending/on-hold only)
11
+ *
12
+ * Checkout is open to guests; order_key possession is the guest bearer credential.
13
+ */
14
+ import { createClientFromRequest } from "npm:@base44/sdk";
15
+ import { HttpError, getCallerUser, ownsEmail } from "../../../shared/commerce/auth.ts";
16
+ import { getSetting, getSettings } from "../../../shared/commerce/settings.ts";
17
+ import { calculateTotals } from "../../../shared/commerce/totals.ts";
18
+ import { validateCoupon } from "../../../shared/commerce/coupons.ts";
19
+ import { checkPurchasable, releaseExpiredHolds } from "../../../shared/commerce/stock.ts";
20
+ import { transitionOrder, serializeOrderForCustomer } from "../../../shared/commerce/orders.ts";
21
+ import { resolveShippingSelection } from "../../../shared/commerce/shipping.ts";
22
+ import {
23
+ confirmOnlinePayment,
24
+ isOnlineGateway,
25
+ isOrderPaid,
26
+ orderMeta,
27
+ resolveReturnUrls,
28
+ SESSION_META_KEY,
29
+ startOnlinePayment,
30
+ } from "../../../shared/commerce/payments.ts";
31
+ import { generateOrderKey, nextOrderNumber } from "../../../shared/commerce/sequence.ts";
32
+ import { round2 } from "../../../shared/commerce/money.ts";
33
+ import {
34
+ couponCtxLines,
35
+ findCoupon,
36
+ loadCart,
37
+ loadPricingData,
38
+ resolveItems,
39
+ } from "./cart-pricing.ts";
40
+
41
+ function ok(data: unknown, status = 200): Response {
42
+ return Response.json({ success: true, data }, { status });
43
+ }
44
+
45
+ function fail(e: unknown): Response {
46
+ if (e instanceof HttpError) {
47
+ return Response.json(
48
+ { success: false, error: e.message, code: e.code ?? "error", ...(e.details ?? {}) },
49
+ { status: e.status },
50
+ );
51
+ }
52
+ console.error("commerce/storefront-checkout error:", e);
53
+ return Response.json(
54
+ { success: false, error: (e as Error)?.message ?? "Internal error", code: "internal_error" },
55
+ { status: 500 },
56
+ );
57
+ }
58
+
59
+ const REQUIRED_BILLING = ["first_name", "last_name", "address_1", "city", "country", "email"];
60
+
61
+ Deno.serve(async (req: Request) => {
62
+ try {
63
+ const base44 = createClientFromRequest(req);
64
+ const sr = base44.asServiceRole;
65
+ const { action, ...payload } = await req.json().catch(() => ({}));
66
+ if (!action) throw new HttpError(400, "action is required.", "action_required");
67
+ const user = await getCallerUser(base44);
68
+
69
+ switch (action) {
70
+ case "place-order":
71
+ return ok(await placeOrder(sr, req, user, payload));
72
+ case "confirm-payment":
73
+ return ok(await confirmPayment(sr, payload));
74
+ case "cancel-order":
75
+ return ok(await cancelOrder(sr, payload));
76
+ default:
77
+ throw new HttpError(400, `Unknown action: ${action}`, "unknown_action");
78
+ }
79
+ } catch (e) {
80
+ return fail(e);
81
+ }
82
+ });
83
+
84
+ async function placeOrder(sr: any, req: Request, user: any, payload: any): Promise<any> {
85
+ const pricingData = await loadPricingData(sr);
86
+ const settings = pricingData.settings;
87
+
88
+ // (1) opportunistically release expired stock holds (no cron on Base44)
89
+ releaseExpiredHolds(sr, settings, transitionOrder).catch((e) =>
90
+ console.error("release-expired-holds failed:", e)
91
+ );
92
+
93
+ // billing validation
94
+ const billing = payload.billing || {};
95
+ const missing = REQUIRED_BILLING.filter((f) => !String(billing[f] ?? "").trim());
96
+ if (missing.length) {
97
+ throw new HttpError(400, `Missing required billing fields: ${missing.join(", ")}.`, "billing_incomplete");
98
+ }
99
+
100
+ // cart
101
+ const cart = await loadCart(sr, payload.cart_token);
102
+ const { resolved, removed } = await resolveItems(sr, cart);
103
+ if (!resolved.length) throw new HttpError(400, "Your cart is empty.", "empty_cart");
104
+ if (removed.length) {
105
+ throw new HttpError(409, "Some items in your cart are no longer available.", "items_unavailable");
106
+ }
107
+
108
+ // (2a) revalidate stock/purchasability at exact quantities
109
+ const unavailable: any[] = [];
110
+ for (const r of resolved) {
111
+ const check = checkPurchasable(r.product, r.variation, r.item.quantity, settings);
112
+ if (!check.ok) {
113
+ unavailable.push({ item_key: r.item.item_key, product_id: r.item.product_id, error: check.error, code: check.code });
114
+ }
115
+ }
116
+ if (unavailable.length) {
117
+ throw new HttpError(409, "Some items in your cart cannot be purchased.", "items_unavailable");
118
+ }
119
+
120
+ // (2b) revalidate coupons — a stale coupon fails checkout loudly (intended behavior)
121
+ const ctxLines = couponCtxLines(resolved);
122
+ const itemsSubtotal = round2(ctxLines.reduce((a: number, l: any) => a + l.subtotal, 0));
123
+ const coupons: any[] = [];
124
+ for (const code of cart.coupon_codes || []) {
125
+ const coupon = await findCoupon(sr, code);
126
+ const res = validateCoupon(coupon, {
127
+ lines: ctxLines,
128
+ itemsSubtotal,
129
+ customerEmail: billing.email,
130
+ appliedCoupons: coupons,
131
+ });
132
+ if (!res.valid) {
133
+ throw new HttpError(409, `Coupon "${code}": ${res.error}`, "coupon_invalid");
134
+ }
135
+ coupons.push(coupon);
136
+ }
137
+
138
+ // shipping requirements
139
+ const needsShipping = resolved.some((r) => !(r.variation?.virtual ?? r.product.virtual));
140
+ const shippingAddress = payload.shipping && payload.shipping.country
141
+ ? payload.shipping
142
+ : (cart.shipping_address?.country ? cart.shipping_address : billingAsShipping(billing));
143
+
144
+ // (3) authoritative totals
145
+ const priceWith = (chosenShippingMethodId?: string) => calculateTotals({
146
+ items: resolved.map((r) => ({
147
+ product: r.product,
148
+ variation: r.variation,
149
+ quantity: r.item.quantity,
150
+ attributes: r.item.attributes ?? [],
151
+ })),
152
+ coupons,
153
+ billing,
154
+ shipping_address: shippingAddress,
155
+ chosenShippingMethodId: chosenShippingMethodId || undefined,
156
+ settings,
157
+ taxRates: pricingData.taxRates,
158
+ zones: pricingData.zones,
159
+ zoneMethods: pricingData.zoneMethods,
160
+ });
161
+
162
+ // The method may be named on this call (`chosen_shipping_method`) or carried on
163
+ // the cart from `choose-shipping-method`; the explicit one wins. Priced once to
164
+ // learn what this address is actually offered, then resolved and — if that
165
+ // changed the method — priced again.
166
+ const requestedMethodId = String(payload.chosen_shipping_method || "");
167
+ let totals = priceWith(requestedMethodId || cart.chosen_shipping_method);
168
+ const shippingEnabled = getSetting(settings, "shipping", "enable_shipping", true) !== false;
169
+
170
+ if (needsShipping && shippingEnabled) {
171
+ const offered = totals.available_shipping_methods;
172
+ // A method named explicitly by the caller must exist — never quietly swap it.
173
+ if (requestedMethodId && !offered.some((m: any) => m.id === requestedMethodId)) {
174
+ throw new HttpError(
175
+ 400,
176
+ "The selected shipping method is not available for this address.",
177
+ "invalid_shipping_method",
178
+ { available_shipping_methods: offered },
179
+ );
180
+ }
181
+ const selection = resolveShippingSelection({
182
+ needsShipping,
183
+ shippingEnabled,
184
+ chosenMethodId: requestedMethodId || cart.chosen_shipping_method,
185
+ available: offered,
186
+ });
187
+ if (selection.state === "none_available") {
188
+ throw new HttpError(
189
+ 400,
190
+ "No shipping method is available for this address.",
191
+ "no_shipping_available",
192
+ { available_shipping_methods: [] },
193
+ );
194
+ }
195
+ if (selection.state === "choice_required") {
196
+ throw new HttpError(
197
+ 400,
198
+ "Please choose a shipping method for your address.",
199
+ "shipping_method_required",
200
+ { available_shipping_methods: offered },
201
+ );
202
+ }
203
+ if (selection.method_id !== (requestedMethodId || cart.chosen_shipping_method || "")) {
204
+ totals = priceWith(selection.method_id); // the single available option
205
+ }
206
+ // Belt and braces: a shippable order must never leave here without a line.
207
+ if (!totals.shipping_lines.length) {
208
+ throw new HttpError(
209
+ 400,
210
+ "Please choose a shipping method for your address.",
211
+ "shipping_method_required",
212
+ { available_shipping_methods: offered },
213
+ );
214
+ }
215
+ }
216
+
217
+ // payment gateway
218
+ const gatewaySlug = String(payload.payment_method || "");
219
+ const gateway = (await sr.entities["commerce.PaymentGateway"].filter({ slug: gatewaySlug }, undefined, 1))?.[0];
220
+ if (!gateway || !gateway.enabled) {
221
+ throw new HttpError(400, "The selected payment method is not available.", "invalid_payment_method");
222
+ }
223
+
224
+ // (4) customer upsert by billing email
225
+ const notices: string[] = [];
226
+ if (payload.create_account && !user) {
227
+ notices.push("account_creation_requires_login"); // see skills/commerce/references/guest-access-security.md
228
+ }
229
+ const customer = await upsertCustomer(sr, billing, shippingAddress, user);
230
+
231
+ // (5) create the pending order
232
+ const holdMinutes = Number(getSetting(settings, "inventory", "hold_stock_minutes", 60)) || 0;
233
+ const now = Date.now();
234
+ const order = await sr.entities["commerce.Order"].create({
235
+ order_number: await nextOrderNumber(sr),
236
+ order_key: generateOrderKey(),
237
+ status: "pending",
238
+ currency: getSetting(settings, "general", "currency", "USD"),
239
+ prices_include_tax: totals.prices_include_tax,
240
+ created_via: "checkout",
241
+ customer_id: customer?.id ?? "",
242
+ customer_note: payload.customer_note ?? "",
243
+ customer_ip: (req.headers.get("x-forwarded-for") ?? "").split(",")[0].trim(),
244
+ customer_user_agent: req.headers.get("user-agent") ?? "",
245
+ billing,
246
+ shipping: needsShipping ? stripShippingEmail(shippingAddress) : {},
247
+ payment_method: gateway.slug,
248
+ payment_method_title: gateway.title ?? gateway.slug,
249
+ transaction_id: "",
250
+ line_items: totals.line_items,
251
+ shipping_lines: totals.shipping_lines,
252
+ tax_lines: totals.tax_lines,
253
+ fee_lines: totals.fee_lines,
254
+ coupon_lines: totals.coupon_lines,
255
+ subtotal: totals.subtotal,
256
+ discount_total: totals.discount_total,
257
+ discount_tax: totals.discount_tax,
258
+ shipping_total: totals.shipping_total,
259
+ shipping_tax: totals.shipping_tax,
260
+ cart_tax: totals.cart_tax,
261
+ total_tax: totals.total_tax,
262
+ total: totals.total,
263
+ total_refunded: 0,
264
+ stock_reduced: false,
265
+ coupon_usages_counted: false,
266
+ download_permissions_granted: false,
267
+ hold_expires_at: holdMinutes > 0 ? new Date(now + holdMinutes * 60_000).toISOString() : null,
268
+ emails_sent: [],
269
+ meta_data: [],
270
+ });
271
+
272
+ // (6) creation effects: stock reduce, new_order email, order.created webhook
273
+ await transitionOrder(sr, order, "pending", { isCreation: true, settings });
274
+
275
+ // (7) cart consumed
276
+ try { await sr.entities["commerce.Cart"].update(cart.id, { status: "converted" }); } catch { /* best-effort */ }
277
+
278
+ // (9) gateway routing
279
+ let paymentInstructions: any = null;
280
+ let payment: any = null;
281
+ switch (gateway.slug) {
282
+ case "cod":
283
+ await transitionOrder(sr, order, "processing", { settings });
284
+ paymentInstructions = { type: "cod", description: gateway.description ?? "Pay with cash upon delivery." };
285
+ break;
286
+ case "bacs":
287
+ await transitionOrder(sr, order, "on-hold", { settings });
288
+ paymentInstructions = {
289
+ type: "bacs",
290
+ description: gateway.description ?? "Make your payment directly into our bank account.",
291
+ account_details: gateway.settings?.account_details ?? [],
292
+ };
293
+ break;
294
+ case "cheque":
295
+ await transitionOrder(sr, order, "on-hold", { settings });
296
+ paymentInstructions = { type: "cheque", description: gateway.description ?? "Please send a check to our store address." };
297
+ break;
298
+ default:
299
+ if (isOnlineGateway(gateway.slug)) {
300
+ // Online payment: the order stays `pending` and the customer is sent to
301
+ // the provider's hosted page. Confirmation comes back through
302
+ // commerce/payments `verify` (return) and commerce/payment-webhook.
303
+ const { successUrl, cancelUrl } = resolveReturnUrls({
304
+ order,
305
+ req,
306
+ successUrl: payload.success_url,
307
+ cancelUrl: payload.cancel_url,
308
+ returnUrl: payload.return_url,
309
+ storeUrl: settings.general?.store_url,
310
+ returnPath: settings.general?.order_received_path,
311
+ });
312
+ const link = await startOnlinePayment(sr, order, {
313
+ successUrl,
314
+ cancelUrl,
315
+ customerEmail: billing.email,
316
+ });
317
+ payment = {
318
+ status: "requires_payment",
319
+ provider: link.provider,
320
+ checkout_url: link.url,
321
+ session_id: link.session_id,
322
+ note: "Send the customer to checkout_url; the order stays pending until the payment is confirmed.",
323
+ };
324
+ } else {
325
+ // custom gateway added by the store: leave pending for external wiring
326
+ payment = { status: "pending_external", note: `Gateway "${gateway.slug}" requires custom wiring.` };
327
+ }
328
+ break;
329
+ }
330
+
331
+ // update customer aggregates for paid statuses
332
+ if (customer && (order.status === "processing" || order.status === "completed")) {
333
+ await bumpCustomerStats(sr, customer, order);
334
+ }
335
+
336
+ return {
337
+ order_id: order.id,
338
+ order_number: order.order_number,
339
+ order_key: order.order_key,
340
+ status: order.status,
341
+ currency: order.currency,
342
+ payment_method: order.payment_method,
343
+ payment_method_title: order.payment_method_title,
344
+ payment_instructions: paymentInstructions,
345
+ payment,
346
+ notices,
347
+ totals: {
348
+ subtotal: order.subtotal,
349
+ discount_total: order.discount_total,
350
+ shipping_total: order.shipping_total,
351
+ shipping_tax: order.shipping_tax,
352
+ cart_tax: order.cart_tax,
353
+ total_tax: order.total_tax,
354
+ total: order.total,
355
+ },
356
+ order: serializeOrderForCustomer(order),
357
+ };
358
+ }
359
+
360
+ /** The order_key says who is asking; only the provider says whether it was paid. */
361
+ async function confirmPayment(sr: any, payload: any): Promise<any> {
362
+ const order = await loadOrderByKey(sr, payload.order_id, payload.order_key);
363
+ if (isOrderPaid(order)) {
364
+ return { order: serializeOrderForCustomer(order), paid: true, already_confirmed: true };
365
+ }
366
+ if (!["pending", "on-hold"].includes(order.status)) {
367
+ throw new HttpError(409, `Order is ${order.status} and cannot be confirmed.`, "invalid_status");
368
+ }
369
+ if (!isOnlineGateway(order.payment_method)) {
370
+ throw new HttpError(
371
+ 400,
372
+ "This order is not paid through the online payment provider, so its payment cannot be confirmed here. Settle it from the admin.",
373
+ "not_an_online_payment",
374
+ );
375
+ }
376
+
377
+ const settings = await getSettings(sr);
378
+ const result = await confirmOnlinePayment(sr, order, {
379
+ sessionId: payload.session_id || orderMeta(order, SESSION_META_KEY),
380
+ settings,
381
+ actor: "customer-return",
382
+ });
383
+ if (!result.paid) {
384
+ throw new HttpError(
385
+ 409,
386
+ "The payment provider has not confirmed a payment for this order.",
387
+ "payment_not_confirmed",
388
+ );
389
+ }
390
+
391
+ if (!result.already_confirmed && order.customer_id) {
392
+ try {
393
+ const customer = await sr.entities["commerce.Customer"].get(order.customer_id);
394
+ if (customer) await bumpCustomerStats(sr, customer, order);
395
+ } catch { /* stats can be recomputed by commerce/admin-tools */ }
396
+ }
397
+ return {
398
+ order: serializeOrderForCustomer(result.order),
399
+ paid: true,
400
+ already_confirmed: result.already_confirmed,
401
+ };
402
+ }
403
+
404
+ async function cancelOrder(sr: any, payload: any): Promise<any> {
405
+ const order = await loadOrderByKey(sr, payload.order_id, payload.order_key);
406
+ if (!["pending", "on-hold"].includes(order.status)) {
407
+ throw new HttpError(409, `Order is ${order.status} and can no longer be cancelled.`, "invalid_status");
408
+ }
409
+ await transitionOrder(sr, order, "cancelled", { note: "Order cancelled by customer." });
410
+ return { order: serializeOrderForCustomer(order) };
411
+ }
412
+
413
+ // ── helpers ─────────────────────────────────────────────────────────────────
414
+
415
+ async function loadOrderByKey(sr: any, orderId: string, orderKey: string): Promise<any> {
416
+ if (!orderId || !orderKey) throw new HttpError(400, "order_id and order_key are required.", "order_key_required");
417
+ let order: any = null;
418
+ try { order = await sr.entities["commerce.Order"].get(orderId); } catch { order = null; }
419
+ if (!order || order.order_key !== orderKey) {
420
+ throw new HttpError(404, "Order not found.", "order_not_found");
421
+ }
422
+ return order;
423
+ }
424
+
425
+ function billingAsShipping(billing: any): any {
426
+ const { email: _e, ...rest } = billing || {};
427
+ return rest;
428
+ }
429
+
430
+ function stripShippingEmail(address: any): any {
431
+ const { email: _e, ...rest } = address || {};
432
+ return rest;
433
+ }
434
+
435
+ /**
436
+ * Find-or-create the Customer for this checkout. Only a caller signed in as the
437
+ * billing email writes the saved profile — otherwise knowing an address would be
438
+ * enough to redirect where someone's next order ships. Guests still attach to the
439
+ * customer, and the order keeps its own billing/shipping copy either way.
440
+ */
441
+ async function upsertCustomer(sr: any, billing: any, shippingAddress: any, user: any): Promise<any> {
442
+ const email = String(billing.email).toLowerCase().trim();
443
+ const existing = (await sr.entities["commerce.Customer"].filter({ email }, undefined, 1))?.[0];
444
+ const owner = ownsEmail(user, email);
445
+ const base = {
446
+ first_name: billing.first_name ?? "",
447
+ last_name: billing.last_name ?? "",
448
+ billing,
449
+ shipping: stripShippingEmail(shippingAddress),
450
+ };
451
+ if (existing) {
452
+ if (!owner) return existing;
453
+ const patch: Record<string, any> = { ...base };
454
+ if (!existing.user_id) {
455
+ patch.user_id = user.id;
456
+ patch.is_guest = false;
457
+ }
458
+ await sr.entities["commerce.Customer"].update(existing.id, patch);
459
+ return { ...existing, ...patch };
460
+ }
461
+ return await sr.entities["commerce.Customer"].create({
462
+ email,
463
+ ...base,
464
+ username: "",
465
+ user_id: owner ? user.id : "",
466
+ is_guest: !owner,
467
+ is_paying_customer: false,
468
+ orders_count: 0,
469
+ total_spent: 0,
470
+ meta_data: [],
471
+ });
472
+ }
473
+
474
+ /** Denormalized customer aggregates (recount available via commerce/admin-tools). */
475
+ async function bumpCustomerStats(sr: any, customer: any, order: any): Promise<void> {
476
+ try {
477
+ await sr.entities["commerce.Customer"].update(customer.id, {
478
+ orders_count: (customer.orders_count ?? 0) + 1,
479
+ total_spent: round2((customer.total_spent ?? 0) + (order.total ?? 0)),
480
+ is_paying_customer: true,
481
+ });
482
+ } catch (e) {
483
+ console.error("customer stats update failed:", e);
484
+ }
485
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Auth guards for backend functions.
3
+ * Admin functions call requireAdmin() before doing anything with asServiceRole.
4
+ */
5
+
6
+ /** Error carrying an HTTP status; function entrypoints translate it to a Response. */
7
+ export class HttpError extends Error {
8
+ status: number;
9
+ code?: string;
10
+ /** Extra fields merged into the error body — e.g. the choices a 400 wants back. */
11
+ details?: Record<string, unknown>;
12
+ constructor(status: number, message: string, code?: string, details?: Record<string, unknown>) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.details = details;
17
+ }
18
+ }
19
+
20
+ /** Current caller, or null when unauthenticated (never throws). */
21
+ export async function getCallerUser(base44: any): Promise<any | null> {
22
+ try {
23
+ return (await base44.auth.me()) ?? null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Is this user record an admin? For endpoints that serve both customers and
31
+ * admins (e.g. commerce/payments, where a customer is authorized by `order_key`
32
+ * instead) — the guard is still per-action, this only classifies the caller.
33
+ */
34
+ export function isAdmin(user: any): boolean {
35
+ return user?.role === "admin";
36
+ }
37
+
38
+ /** Ensure there is a signed-in caller. */
39
+ export function requireUser(user: any): any {
40
+ if (!user) throw new HttpError(401, "Please log in.", "login_required");
41
+ return user;
42
+ }
43
+
44
+ /** Does this caller own the given email address? */
45
+ export function ownsEmail(user: any, email: string): boolean {
46
+ const mine = String(user?.email ?? "").toLowerCase().trim();
47
+ return !!mine && mine === String(email ?? "").toLowerCase().trim();
48
+ }
49
+
50
+ /**
51
+ * Ensure the caller is an authenticated admin. Returns the user record.
52
+ * Throws HttpError 401 (unauthenticated) or 403 (not admin).
53
+ * Note: entity RLS is the second line of defense — this guard is the first.
54
+ */
55
+ export async function requireAdmin(base44: any): Promise<any> {
56
+ const user = await getCallerUser(base44);
57
+ if (!user) throw new HttpError(401, "Authentication required", "unauthenticated");
58
+ if (user.role !== "admin") throw new HttpError(403, "Admin role required", "forbidden");
59
+ return user;
60
+ }