@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,495 @@
1
+ // Deploy marker: 1
2
+ // Bump after connecting a payment provider: env vars are injected at deploy time,
3
+ // and editing this file redeploys every commerce/* function.
4
+ // See skills/commerce/references/online-payments.md §2.
5
+ /**
6
+ * Online payments — the provider-neutral utility every caller uses.
7
+ *
8
+ * Nothing outside this file (and the adapter it points at) knows which payment
9
+ * provider the store uses. Checkout, the payment-link/verify function, the
10
+ * webhook and refunds all call the operations below; the admin UI talks about
11
+ * "an online payment provider", not a brand.
12
+ *
13
+ * **The wiring** is one line — `ACTIVE_PROVIDER`. It is bound to the Stripe
14
+ * adapter (`stripe.ts`), which is fully implemented: hosted payment page,
15
+ * payment links, return-verification, webhook signature checking and refunds.
16
+ *
17
+ * **To use a different provider** (PayPal, Adyen, a local PSP): write an adapter
18
+ * that satisfies `PaymentAdapter` and point `ACTIVE_PROVIDER` at it. That is the
19
+ * whole change on the backend — no caller, no UI and no entity needs touching.
20
+ * See skills/commerce/references/online-payments.md.
21
+ *
22
+ * Credentials always come from the host — an injected secret (Base44 puts the
23
+ * Stripe keys in `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` once Stripe is
24
+ * set up for the app) or a connector — never from the `commerce.PaymentGateway`
25
+ * entity and never from the client.
26
+ */
27
+ import { HttpError } from "./auth.ts";
28
+ import { round2 } from "./money.ts";
29
+ import { transitionOrder } from "./orders.ts";
30
+ import * as stripe from "./stripe.ts";
31
+
32
+ /** A hosted-payment session: the page a customer pays on. */
33
+ export interface PaymentSession {
34
+ id: string;
35
+ url: string;
36
+ /** "paid" once the provider has the money. */
37
+ paid: boolean;
38
+ /** Provider's payment/charge id, stored on the order for refunds. */
39
+ payment_reference: string;
40
+ expires_at?: number;
41
+ /** The order this session was opened for, as the provider recorded it. */
42
+ order_id: string;
43
+ order_key: string;
44
+ }
45
+
46
+ export interface PaymentConnection {
47
+ connected: boolean;
48
+ error?: string;
49
+ }
50
+
51
+ /** What a provider implementation has to offer. Keep these signatures. */
52
+ export interface PaymentAdapter {
53
+ /** Machine id (e.g. "stripe"); also the connector name on hosts that use one. */
54
+ id: string;
55
+ /** Human label — the only place a provider brand belongs in the backend. */
56
+ label: string;
57
+ /** The `commerce.PaymentGateway` slug this provider backs. */
58
+ gatewaySlug: string;
59
+ connection(sr: any): Promise<PaymentConnection>;
60
+ createSession(sr: any, order: any, opts: {
61
+ successUrl: string;
62
+ cancelUrl: string;
63
+ customerEmail?: string;
64
+ }): Promise<PaymentSession>;
65
+ retrieveSession(sr: any, sessionId: string): Promise<PaymentSession>;
66
+ /**
67
+ * Invalidate a previously issued payment page, if the provider supports it, so
68
+ * an old link can't be paid after the order changed. Best-effort.
69
+ */
70
+ expireSession?(sr: any, sessionId: string): Promise<boolean>;
71
+ refund(sr: any, opts: {
72
+ paymentReference: string;
73
+ amount: number;
74
+ currency: string;
75
+ reason?: string;
76
+ idempotencyKey?: string;
77
+ }): Promise<{ id: string; status: string; amount: number }>;
78
+ /** True when the raw webhook body really came from the provider. */
79
+ verifyWebhook(opts: { payload: string; header: string; secret: string }): Promise<boolean>;
80
+ /** Pull the fields the order lifecycle needs out of a webhook body. */
81
+ parseWebhookEvent(body: any): {
82
+ type: string;
83
+ paid: boolean;
84
+ order_id: string;
85
+ order_key: string;
86
+ session_id: string;
87
+ payment_reference: string;
88
+ };
89
+ }
90
+
91
+ // ── the Stripe adapter ───────────────────────────────────────────────────────
92
+
93
+ function toPaymentSession(session: stripe.CheckoutSession): PaymentSession {
94
+ return {
95
+ id: session.id,
96
+ url: session.url,
97
+ paid: session.payment_status === "paid",
98
+ payment_reference: session.payment_intent,
99
+ expires_at: session.expires_at,
100
+ order_id: session.order_id,
101
+ order_key: session.order_key,
102
+ };
103
+ }
104
+
105
+ const stripeAdapter: PaymentAdapter = {
106
+ id: "stripe",
107
+ label: "Stripe",
108
+ gatewaySlug: "stripe",
109
+
110
+ async connection(sr) {
111
+ // Verified, not just present: a disconnected or rotated key must stop
112
+ // reading as connected, or the store advertises card payment it can't take.
113
+ const check = await stripe.verifyStripeCredential(sr);
114
+ return { connected: check.ok, error: check.error };
115
+ },
116
+
117
+ async createSession(sr, order, opts) {
118
+ return toPaymentSession(await stripe.createCheckoutSession(sr, order, opts));
119
+ },
120
+
121
+ async retrieveSession(sr, sessionId) {
122
+ return toPaymentSession(await stripe.retrieveCheckoutSession(sr, sessionId));
123
+ },
124
+
125
+ expireSession(sr, sessionId) {
126
+ return stripe.expireCheckoutSession(sr, sessionId);
127
+ },
128
+
129
+ refund(sr, opts) {
130
+ return stripe.createStripeRefund(sr, {
131
+ paymentIntentId: opts.paymentReference,
132
+ amount: opts.amount,
133
+ currency: opts.currency,
134
+ reason: opts.reason,
135
+ idempotencyKey: opts.idempotencyKey,
136
+ });
137
+ },
138
+
139
+ verifyWebhook(opts) {
140
+ return stripe.verifyWebhookSignature(opts);
141
+ },
142
+
143
+ parseWebhookEvent(body) {
144
+ const type = String(body?.type ?? "");
145
+ const object = body?.data?.object ?? {};
146
+ const metadata = object.metadata ?? {};
147
+ const paid = type === "checkout.session.completed"
148
+ ? object.payment_status === "paid"
149
+ : type === "payment_intent.succeeded";
150
+ return {
151
+ type,
152
+ paid,
153
+ order_id: String(metadata.order_id ?? ""),
154
+ order_key: String(metadata.order_key ?? ""),
155
+ session_id: type.startsWith("checkout.session") ? String(object.id ?? "") : "",
156
+ payment_reference: type.startsWith("checkout.session")
157
+ ? String(typeof object.payment_intent === "string" ? object.payment_intent : object.payment_intent?.id ?? "")
158
+ : String(object.id ?? ""),
159
+ };
160
+ },
161
+ };
162
+
163
+ /** ← The one line to change when moving to another provider. */
164
+ export const ACTIVE_PROVIDER: PaymentAdapter = stripeAdapter;
165
+
166
+ // ── order bookkeeping (provider-neutral meta keys) ───────────────────────────
167
+
168
+ export const SESSION_META_KEY = "_payment_session_id";
169
+ export const REFERENCE_META_KEY = "_payment_reference";
170
+
171
+ export function orderMeta(order: any, key: string): string {
172
+ return String((order?.meta_data ?? []).find((m: any) => m?.key === key)?.value ?? "");
173
+ }
174
+
175
+ /** meta_data with these keys replaced (empty values remove the key). */
176
+ export function withOrderMeta(order: any, entries: Record<string, string>): any[] {
177
+ const meta = (order?.meta_data ?? []).filter((m: any) => !Object.hasOwn(entries, m?.key));
178
+ for (const [key, value] of Object.entries(entries)) {
179
+ if (value) meta.push({ key, value });
180
+ }
181
+ return meta;
182
+ }
183
+
184
+ // ── return URLs ──────────────────────────────────────────────────────────────
185
+
186
+ /** An absolute http(s) URL — what every provider requires for its return URLs. */
187
+ export function isAbsoluteUrl(value: unknown): boolean {
188
+ try {
189
+ const url = new URL(String(value));
190
+ return url.protocol === "http:" || url.protocol === "https:";
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * First candidate that is a usable absolute origin, without a trailing slash.
198
+ *
199
+ * Providers reject relative paths ("Not a valid URL"), so the return URLs have
200
+ * to be built from a real origin. Callers pass their candidates in order of
201
+ * authority — an explicit `return_url`, the store's configured URL, then the
202
+ * origin the request came from (the admin or storefront calling this lives on the
203
+ * app's own host, which is exactly where the customer should land back).
204
+ */
205
+ export function resolveReturnBase(candidates: Array<unknown>): string {
206
+ for (const candidate of candidates) {
207
+ const value = String(candidate ?? "").trim();
208
+ if (value && isAbsoluteUrl(value)) return value.replace(/\/+$/, "");
209
+ }
210
+ return "";
211
+ }
212
+
213
+ /** The origin of the caller, from the request headers ("" when absent). */
214
+ export function requestOrigin(req: Request): string {
215
+ const origin = req.headers.get("origin");
216
+ if (origin && isAbsoluteUrl(origin)) return origin;
217
+ const referer = req.headers.get("referer");
218
+ if (referer && isAbsoluteUrl(referer)) {
219
+ try { return new URL(referer).origin; } catch { /* ignore */ }
220
+ }
221
+ return "";
222
+ }
223
+
224
+ /** Default path of the return page, when the store hasn't said otherwise. */
225
+ export const DEFAULT_RETURN_PATH = "/order-received";
226
+
227
+ /**
228
+ * Add the ids the return page needs to verify the payment, keeping existing
229
+ * query params. Applied to caller-supplied URLs too: without these they can't
230
+ * confirm anything.
231
+ */
232
+ export function withReturnParams(url: string, order: any, outcome: "success" | "cancel"): string {
233
+ const parsed = new URL(url);
234
+ parsed.searchParams.set("order_id", String(order.id));
235
+ parsed.searchParams.set("order_key", String(order.order_key ?? ""));
236
+ parsed.searchParams.set("payment", outcome);
237
+ return parsed.toString();
238
+ }
239
+
240
+ /**
241
+ * Where the customer lands after paying: an origin plus the store's return path
242
+ * (`settings.general.order_received_path`), which is configuration — a hardcoded
243
+ * guess sends payers to a 404.
244
+ */
245
+ export function orderReturnUrl(base: string, order: any, outcome: "success" | "cancel", path?: unknown): string {
246
+ const origin = String(base).replace(/\/+$/, "");
247
+ const rawPath = String(path ?? "").trim() || DEFAULT_RETURN_PATH;
248
+ const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
249
+ return withReturnParams(`${origin}${withSlash}`, order, outcome);
250
+ }
251
+
252
+ /**
253
+ * The pair of absolute return URLs for a payment session, or a 400 that says how
254
+ * to fix it — better than letting the provider answer "Not a valid URL".
255
+ *
256
+ * Precedence: `successUrl`/`cancelUrl` per outcome → `returnUrl` (the return
257
+ * page, both outcomes) → `storeUrl`/request origin + `returnPath`. Every result
258
+ * carries `order_id`, `order_key` and `payment`.
259
+ */
260
+ export function resolveReturnUrls(opts: {
261
+ order: any;
262
+ req?: Request;
263
+ successUrl?: unknown;
264
+ cancelUrl?: unknown;
265
+ returnUrl?: unknown;
266
+ storeUrl?: unknown;
267
+ returnPath?: unknown;
268
+ }): { successUrl: string; cancelUrl: string } {
269
+ const base = resolveReturnBase([opts.storeUrl, opts.req ? requestOrigin(opts.req) : ""]);
270
+
271
+ // A `returnUrl` naming a page is used as-is; a bare origin has no page in it,
272
+ // so it is treated as a base and gets the store's return path appended.
273
+ const returnUrlIsPage = isAbsoluteUrl(opts.returnUrl) &&
274
+ new URL(String(opts.returnUrl)).pathname.replace(/\/+$/, "") !== "";
275
+ const returnUrlAsBase = isAbsoluteUrl(opts.returnUrl) && !returnUrlIsPage
276
+ ? String(opts.returnUrl).replace(/\/+$/, "")
277
+ : "";
278
+
279
+ const pick = (specific: unknown, outcome: "success" | "cancel") => {
280
+ if (isAbsoluteUrl(specific)) return withReturnParams(String(specific), opts.order, outcome);
281
+ if (returnUrlIsPage) return withReturnParams(String(opts.returnUrl), opts.order, outcome);
282
+ const origin = returnUrlAsBase || base;
283
+ return origin ? orderReturnUrl(origin, opts.order, outcome, opts.returnPath) : "";
284
+ };
285
+
286
+ const success = pick(opts.successUrl, "success");
287
+ const cancel = pick(opts.cancelUrl, "cancel");
288
+
289
+ if (!success || !cancel) {
290
+ throw new HttpError(
291
+ 400,
292
+ "Can't build the return URLs for the payment page: pass an absolute success_url/cancel_url (or return_url pointing at your return page), or set the store URL in the store's general settings.",
293
+ "return_url_required",
294
+ { needs: ["success_url", "cancel_url"], hint: "settings.general.store_url + order_received_path" },
295
+ );
296
+ }
297
+ return { successUrl: success, cancelUrl: cancel };
298
+ }
299
+
300
+ /**
301
+ * What a payment-return page should show, decided **server-side** from the
302
+ * confirmed payment state plus the outcome the provider claims in the URL.
303
+ *
304
+ * The URL is only ever a hint (`?payment=success` is editable by anyone), so
305
+ * `paid` — the answer from the provider — always wins over it.
306
+ */
307
+ export function paymentReturnState(input: { paid: boolean; outcome?: string }):
308
+ "paid" | "cancelled" | "unpaid" {
309
+ if (input.paid) return "paid";
310
+ return input.outcome === "cancel" ? "cancelled" : "unpaid";
311
+ }
312
+
313
+ // ── provider-neutral operations ──────────────────────────────────────────────
314
+
315
+ /** Does this gateway slug mean "pay online with the configured provider"? */
316
+ export function isOnlineGateway(slug: string): boolean {
317
+ return String(slug || "") === ACTIVE_PROVIDER.gatewaySlug;
318
+ }
319
+
320
+ /**
321
+ * Whether the store can actually take an online payment right now.
322
+ *
323
+ * A *live fact about the host's payment setup*, never a constant: surface it from
324
+ * here so a "no payment provider connected" state disappears by itself once the
325
+ * provider is set up.
326
+ */
327
+ export async function onlinePaymentStatus(sr: any): Promise<{
328
+ provider: string;
329
+ provider_label: string;
330
+ gateway_slug: string;
331
+ connected: boolean;
332
+ error?: string;
333
+ }> {
334
+ const conn = await ACTIVE_PROVIDER.connection(sr);
335
+ return {
336
+ provider: ACTIVE_PROVIDER.id,
337
+ provider_label: ACTIVE_PROVIDER.label,
338
+ gateway_slug: ACTIVE_PROVIDER.gatewaySlug,
339
+ connected: conn.connected,
340
+ ...(conn.error ? { error: conn.error } : {}),
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Start (or resume) payment for an order: returns the hosted page to send the
346
+ * customer to. Used by checkout right after the order is created **and** by the
347
+ * admin to produce a payment link for an unpaid order — same session either way,
348
+ * so both routes behave identically.
349
+ *
350
+ * The session id and payment reference are stored on the order so the return
351
+ * handler and the webhook can confirm without trusting anything in the URL.
352
+ */
353
+ export async function startOnlinePayment(sr: any, order: any, opts: {
354
+ successUrl: string;
355
+ cancelUrl: string;
356
+ customerEmail?: string;
357
+ }): Promise<{ provider: string; session_id: string; url: string; expires_at?: number }> {
358
+ if (isOrderPaid(order)) {
359
+ throw new HttpError(409, "This order is already paid.", "already_paid");
360
+ }
361
+
362
+ // Retire the previous payment page before minting a new one: after an order is
363
+ // edited, an older link would otherwise still be payable at the old total.
364
+ const previousSession = orderMeta(order, SESSION_META_KEY);
365
+ if (previousSession && ACTIVE_PROVIDER.expireSession) {
366
+ try {
367
+ await ACTIVE_PROVIDER.expireSession(sr, previousSession);
368
+ } catch {
369
+ // already completed or expired — nothing to do
370
+ }
371
+ }
372
+
373
+ const session = await ACTIVE_PROVIDER.createSession(sr, order, opts);
374
+ if (!session.url) {
375
+ throw new HttpError(502, "The payment provider did not return a payment page.", "payment_session_failed");
376
+ }
377
+ await sr.entities["commerce.Order"].update(order.id, {
378
+ meta_data: withOrderMeta(order, {
379
+ [SESSION_META_KEY]: session.id,
380
+ [REFERENCE_META_KEY]: session.payment_reference,
381
+ }),
382
+ });
383
+ return {
384
+ provider: ACTIVE_PROVIDER.id,
385
+ session_id: session.id,
386
+ url: session.url,
387
+ expires_at: session.expires_at,
388
+ };
389
+ }
390
+
391
+ /** Paid enough to stop asking for money? */
392
+ export function isOrderPaid(order: any): boolean {
393
+ return Boolean(order?.date_paid) || ["processing", "completed", "refunded"].includes(order?.status);
394
+ }
395
+
396
+ /**
397
+ * Session ids reach us from places the customer controls (a return URL, a webhook
398
+ * body), so without this anyone could pay a cheap order of their own and replay
399
+ * that session against an expensive one — or someone else's.
400
+ */
401
+ function assertSessionBelongsToOrder(session: PaymentSession, order: any): void {
402
+ const sameOrder = session.order_id && session.order_id === String(order.id);
403
+ const sameKey = !session.order_key || session.order_key === String(order.order_key ?? "");
404
+ if (!sameOrder || !sameKey) {
405
+ throw new HttpError(
406
+ 409,
407
+ "That payment belongs to a different order.",
408
+ "session_order_mismatch",
409
+ );
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Confirm a payment and move the order on — **idempotent**, so the webhook and
415
+ * the customer's return can both run in any order and only the first one has an
416
+ * effect (no double stock reduction, coupon counting or emails).
417
+ *
418
+ * Whether money arrived is always asked of the provider; `trustedPaid` (a
419
+ * signature-verified event) only stands in when there is no session to fetch.
420
+ *
421
+ * With no `sessionId`, the session stored on the order is used, which is what a
422
+ * "check payment status" button in the admin wants.
423
+ */
424
+ export async function confirmOnlinePayment(sr: any, order: any, opts: {
425
+ sessionId?: string;
426
+ paymentReference?: string;
427
+ settings?: Record<string, any>;
428
+ actor?: string;
429
+ trustedPaid?: boolean;
430
+ } = {}): Promise<{ paid: boolean; already_confirmed: boolean; order: any }> {
431
+ if (isOrderPaid(order)) return { paid: true, already_confirmed: true, order };
432
+
433
+ let paid = false;
434
+ let reference = opts.paymentReference ?? "";
435
+
436
+ const storedSessionId = orderMeta(order, SESSION_META_KEY);
437
+ const sessionId = opts.sessionId || storedSessionId;
438
+ if (sessionId) {
439
+ const session = await ACTIVE_PROVIDER.retrieveSession(sr, sessionId);
440
+ // The order's own session was minted for it; one from the request must prove it.
441
+ if (sessionId !== storedSessionId) assertSessionBelongsToOrder(session, order);
442
+ // A payment intent can succeed a moment before its session reads paid.
443
+ paid = session.paid || !!opts.trustedPaid;
444
+ reference = reference || session.payment_reference;
445
+ } else {
446
+ paid = !!opts.trustedPaid;
447
+ }
448
+ if (!paid) return { paid: false, already_confirmed: false, order };
449
+
450
+ if (reference) {
451
+ await sr.entities["commerce.Order"].update(order.id, {
452
+ transaction_id: reference,
453
+ meta_data: withOrderMeta(order, { [REFERENCE_META_KEY]: reference }),
454
+ });
455
+ order.transaction_id = reference;
456
+ }
457
+ await transitionOrder(sr, order, "processing", {
458
+ settings: opts.settings,
459
+ actor: opts.actor ?? "payment-provider",
460
+ note: `Payment received via ${ACTIVE_PROVIDER.label}${reference ? ` (${reference})` : ""}.`,
461
+ });
462
+ return { paid: true, already_confirmed: false, order };
463
+ }
464
+
465
+ /**
466
+ * Refund money at the provider. Called *before* the local refund record is
467
+ * written, so a failed provider refund never leaves a phantom refund behind.
468
+ * Returns null when the order has no online payment to refund.
469
+ */
470
+ export async function refundOnlinePayment(sr: any, order: any, opts: {
471
+ amount: number;
472
+ reason?: string;
473
+ idempotencyKey?: string;
474
+ }): Promise<{ provider: string; id: string; status: string; amount: number } | null> {
475
+ const reference = order?.transaction_id || orderMeta(order, REFERENCE_META_KEY);
476
+ if (!isOnlineGateway(order?.payment_method) || !reference) return null;
477
+
478
+ const refund = await ACTIVE_PROVIDER.refund(sr, {
479
+ paymentReference: reference,
480
+ amount: round2(opts.amount),
481
+ currency: String(order.currency || "USD"),
482
+ reason: opts.reason,
483
+ idempotencyKey: opts.idempotencyKey,
484
+ });
485
+ return { provider: ACTIVE_PROVIDER.id, ...refund };
486
+ }
487
+
488
+ /** Webhook plumbing, provider-neutral. */
489
+ export function verifyPaymentWebhook(opts: { payload: string; header: string; secret: string }): Promise<boolean> {
490
+ return ACTIVE_PROVIDER.verifyWebhook(opts);
491
+ }
492
+
493
+ export function parsePaymentWebhook(body: any) {
494
+ return ACTIVE_PROVIDER.parseWebhookEvent(body);
495
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Review aggregates: keep Product.average_rating / rating_count in sync with
3
+ * approved reviews. Called by commerce/admin-reviews on any moderation change and by
4
+ * commerce/storefront-catalog when auto-approve is enabled.
5
+ */
6
+ import { round2 } from "./money.ts";
7
+
8
+ /** Recompute a product's rating aggregates from its APPROVED reviews. */
9
+ export async function recalcProductRating(sr: any, productId: string): Promise<{ average: number; count: number }> {
10
+ const approved: any[] = [];
11
+ let skip = 0;
12
+ // paginate defensively; a product rarely has >500 reviews but be correct
13
+ while (true) {
14
+ const page = (await sr.entities["commerce.ProductReview"].filter(
15
+ { product_id: productId, status: "approved" },
16
+ "-created_date",
17
+ 500,
18
+ skip,
19
+ )) ?? [];
20
+ approved.push(...page);
21
+ if (page.length < 500) break;
22
+ skip += 500;
23
+ }
24
+
25
+ const rated = approved.filter((r) => typeof r.rating === "number" && r.rating > 0);
26
+ const count = approved.length;
27
+ const average = rated.length
28
+ ? round2(rated.reduce((a, r) => a + r.rating, 0) / rated.length)
29
+ : 0;
30
+
31
+ await sr.entities["commerce.Product"].update(productId, {
32
+ average_rating: average,
33
+ rating_count: count,
34
+ });
35
+ return { average, count };
36
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Full-collection scan helper. Base44's filter() is exact-match with a 5k page
3
+ * cap and no total count, so server-side search/aggregation loops pages of 500.
4
+ * Keep `cap` sane — reports over very large stores should move to a
5
+ * materialized stats entity (see skills/commerce/references/limits-and-performance.md).
6
+ */
7
+
8
+ export interface ScanOpts {
9
+ /** Hard cap on records scanned (default 10_000). */
10
+ cap?: number;
11
+ /** Page size per request (default 500). */
12
+ pageSize?: number;
13
+ /** Optional field projection passed through to filter(). */
14
+ fields?: string[];
15
+ }
16
+
17
+ /**
18
+ * Scan every record matching `query` (null/{} = all records via list()).
19
+ * `entityApi` is e.g. `sr.entities["commerce.Order"]`.
20
+ *
21
+ * The 4th arg accepts either a plain number (treated as `cap`) or a full
22
+ * `ScanOpts` object, so both `scanAll(e, {}, "order", 500)` and
23
+ * `scanAll(e, {}, "order", { cap: 500 })` call styles are valid.
24
+ */
25
+ export async function scanAll(
26
+ entityApi: any,
27
+ query: Record<string, any> | null = null,
28
+ sort = "-created_date",
29
+ optsOrCap: ScanOpts | number = {},
30
+ ): Promise<any[]> {
31
+ const opts: ScanOpts = typeof optsOrCap === "number" ? { cap: optsOrCap } : optsOrCap;
32
+ const cap = opts.cap ?? 10_000;
33
+ const pageSize = opts.pageSize ?? 500;
34
+ const out: any[] = [];
35
+ let skip = 0;
36
+ while (out.length < cap) {
37
+ const page = query && Object.keys(query).length
38
+ ? (await entityApi.filter(query, sort, pageSize, skip, opts.fields)) ?? []
39
+ : (await entityApi.list(sort, pageSize, skip)) ?? [];
40
+ out.push(...page);
41
+ if (page.length < pageSize) break;
42
+ skip += pageSize;
43
+ }
44
+ return out.slice(0, cap);
45
+ }
46
+
47
+ /** Case-insensitive "haystack contains needle" for server-side search actions. */
48
+ export function textMatch(haystack: unknown, needle: string): boolean {
49
+ if (!needle) return true;
50
+ return String(haystack ?? "").toLowerCase().includes(needle.toLowerCase());
51
+ }
52
+
53
+ /** Slice a filtered array into a page + has_next probe (limit+1 convention). */
54
+ export function pageSlice<T>(rows: T[], limit = 20, skip = 0): { rows: T[]; has_next: boolean } {
55
+ const page = rows.slice(skip, skip + limit + 1);
56
+ return { rows: page.slice(0, limit), has_next: page.length > limit };
57
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Order-number sequencing and opaque key/id generation.
3
+ *
4
+ * NOTE ON CONCURRENCY: Base44 has no transactions, so nextOrderNumber() is a
5
+ * read-max-then-increment and two simultaneous checkouts could race. The window
6
+ * is tiny and order ids (not numbers) are the primary key; see
7
+ * skills/commerce/references/limits-and-performance.md for mitigations.
8
+ */
9
+
10
+ const ORDER_NUMBER_START = 1001;
11
+
12
+ /** Next human-facing sequential order number (max existing + 1, starts at 1001). */
13
+ export async function nextOrderNumber(sr: any): Promise<number> {
14
+ const latest = (await sr.entities["commerce.Order"].list("-order_number", 1)) ?? [];
15
+ const max = Number(latest[0]?.order_number ?? 0);
16
+ return Math.max(max + 1, ORDER_NUMBER_START);
17
+ }
18
+
19
+ /**
20
+ * Random order key used as a bearer credential for guest order tracking
21
+ * (an unguessable per-order key). Treat like a secret; only ever share with the
22
+ * order's owner.
23
+ */
24
+ export function generateOrderKey(): string {
25
+ const bytes = new Uint8Array(18);
26
+ crypto.getRandomValues(bytes);
27
+ let key = "";
28
+ for (const b of bytes) key += (b % 36).toString(36);
29
+ return `order_${key.slice(0, 24)}`;
30
+ }
31
+
32
+ /** Stable uuid for embedded line ids, cart tokens, item keys. */
33
+ export function uuid(): string {
34
+ return crypto.randomUUID();
35
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * StoreSettings access. Settings are stored one record per group:
3
+ * { group_id: "general" | "products" | "inventory" | "tax"
4
+ * | "shipping" | "emails", values: {...} }
5
+ */
6
+
7
+ /**
8
+ * Fetch settings groups in one list() call.
9
+ * Returns { [group_id]: values }. Pass no groupIds to get every group.
10
+ * Callers should fetch once per invocation and pass the object around.
11
+ */
12
+ export async function getSettings(sr: any, ...groupIds: string[]): Promise<Record<string, any>> {
13
+ const records = (await sr.entities["commerce.StoreSettings"].list(undefined, 100)) ?? [];
14
+ const out: Record<string, any> = {};
15
+ for (const rec of records) {
16
+ if (!rec?.group_id) continue;
17
+ if (groupIds.length && !groupIds.includes(rec.group_id)) continue;
18
+ out[rec.group_id] = rec.values ?? {};
19
+ }
20
+ return out;
21
+ }
22
+
23
+ /** Read one key from a fetched groups object with a fallback. */
24
+ export function getSetting(groups: Record<string, any>, group: string, key: string, fallback?: any): any {
25
+ const v = groups?.[group]?.[key];
26
+ return v === undefined || v === null ? fallback : v;
27
+ }
28
+
29
+ /**
30
+ * Project only the settings that are safe to expose to anonymous storefront
31
+ * callers (used by commerce/storefront-catalog `get-store-info`). Never expose the
32
+ * emails group, notification recipients, or internal thresholds.
33
+ */
34
+ export function storefrontSafeSettings(groups: Record<string, any>): Record<string, any> {
35
+ const general = groups.general ?? {};
36
+ const products = groups.products ?? {};
37
+ const tax = groups.tax ?? {};
38
+ const inventory = groups.inventory ?? {};
39
+ return {
40
+ store_name: general.store_name ?? "",
41
+ currency: general.currency ?? "USD",
42
+ currency_position: general.currency_position ?? "left",
43
+ thousand_sep: general.thousand_sep ?? ",",
44
+ decimal_sep: general.decimal_sep ?? ".",
45
+ num_decimals: general.num_decimals ?? 2,
46
+ enable_taxes: general.enable_taxes ?? true,
47
+ enable_coupons: general.enable_coupons ?? true,
48
+ weight_unit: products.weight_unit ?? "kg",
49
+ dimension_unit: products.dimension_unit ?? "cm",
50
+ enable_reviews: products.enable_reviews ?? true,
51
+ review_rating_required: products.review_rating_required ?? true,
52
+ prices_include_tax: tax.prices_include_tax ?? false,
53
+ display_prices_shop: tax.display_prices_shop ?? "excl",
54
+ display_prices_cart: tax.display_prices_cart ?? "excl",
55
+ hide_out_of_stock: inventory.hide_out_of_stock ?? false,
56
+ };
57
+ }