@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,463 @@
1
+ /**
2
+ * Stripe payments — the ONE module that talks to Stripe.
3
+ *
4
+ * Everything provider-specific lives here: credential lookup, Checkout Session
5
+ * creation (used both for the storefront redirect and for the admin's payment
6
+ * link), session retrieval, refunds, and webhook signature verification. Nothing
7
+ * calls this module directly — everything goes through the provider-neutral
8
+ * utility in `payments.ts`, which is where the adapter is bound.
9
+ *
10
+ * **Swapping in another provider** (PayPal, Adyen, a local PSP): write an adapter
11
+ * in `payments.ts` against that API — see
12
+ * skills/commerce/references/online-payments.md.
13
+ *
14
+ * Credentials come from the host, never from the `commerce.PaymentGateway`
15
+ * entity and never from the client: on Base44, setting up Stripe for the app
16
+ * injects `STRIPE_SECRET_KEY` into backend functions (see `stripeConnection`).
17
+ */
18
+ import { HttpError } from "./auth.ts";
19
+ import { round2 } from "./money.ts";
20
+
21
+ const STRIPE_API = "https://api.stripe.com/v1";
22
+
23
+ /** Currencies Stripe expects in whole units (no cents). */
24
+ const ZERO_DECIMAL = new Set([
25
+ "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG",
26
+ "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF",
27
+ ]);
28
+
29
+ /** Amount → Stripe's smallest currency unit. 12.34 USD → 1234; 1200 JPY → 1200. */
30
+ export function toMinorUnits(amount: number, currency: string): number {
31
+ const value = Number(amount) || 0;
32
+ if (ZERO_DECIMAL.has(String(currency || "").toUpperCase())) return Math.round(value);
33
+ return Math.round(round2(value) * 100);
34
+ }
35
+
36
+ /** Stripe's smallest unit → a display amount, for reading values back. */
37
+ export function fromMinorUnits(minor: number, currency: string): number {
38
+ const value = Number(minor) || 0;
39
+ if (ZERO_DECIMAL.has(String(currency || "").toUpperCase())) return value;
40
+ return round2(value / 100);
41
+ }
42
+
43
+ export interface StripeConnection {
44
+ connected: boolean;
45
+ accessToken?: string;
46
+ /** Where the credential came from — useful when diagnosing setup. */
47
+ source?: "env" | "connector";
48
+ error?: string;
49
+ }
50
+
51
+ /** `STRIPE_SECRET_KEY`, injected by the host's Stripe integration. */
52
+ function secretKeyFromEnv(): string {
53
+ try {
54
+ return String(Deno.env.get("STRIPE_SECRET_KEY") ?? "");
55
+ } catch {
56
+ return ""; // no env access in this runtime
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Can we call Stripe, and with what credential? Never throws — "not set up yet"
62
+ * is a normal state (the store just can't take cards), so callers branch on
63
+ * `connected` instead of catching.
64
+ *
65
+ * Two credential sources, in order:
66
+ *
67
+ * 1. **`STRIPE_SECRET_KEY` in the environment** — this is how Base44 works.
68
+ * Stripe is a *platform integration* there (app dashboard → Integrations →
69
+ * Stripe), not an OAuth connector: the platform holds the keys and injects
70
+ * `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` into backend functions.
71
+ * Verified on a hosted app — a function's whole environment is
72
+ * `BASE44_APP_ID`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`.
73
+ * 2. **A `stripe` OAuth connector**, for hosts that expose Stripe that way.
74
+ * There is no such connector in Base44's catalog, so this is a portability
75
+ * fallback rather than the Base44 path.
76
+ */
77
+ export async function stripeConnection(sr: any): Promise<StripeConnection> {
78
+ const envKey = secretKeyFromEnv();
79
+ if (envKey) return { connected: true, accessToken: envKey, source: "env" };
80
+
81
+ try {
82
+ const conn = await sr?.connectors?.getConnection?.("stripe");
83
+ const accessToken = conn?.accessToken ?? conn?.access_token ?? "";
84
+ if (accessToken) return { connected: true, accessToken, source: "connector" };
85
+ } catch {
86
+ // fall through — an absent connector is not an error worth surfacing
87
+ }
88
+ return {
89
+ connected: false,
90
+ error: "No Stripe credentials: set up Stripe for this app (its keys arrive as STRIPE_SECRET_KEY).",
91
+ };
92
+ }
93
+
94
+ export interface CredentialCheck {
95
+ /** The credential exists *and* the provider accepts it. */
96
+ ok: boolean;
97
+ /** The provider actively rejected it (revoked, rotated, wrong mode). */
98
+ rejected: boolean;
99
+ error?: string;
100
+ }
101
+
102
+ /** Short-lived per-isolate cache, keyed by token so a key change busts it. */
103
+ let credentialCache: { token: string; at: number; result: CredentialCheck } | null = null;
104
+ const CREDENTIAL_TTL_MS = 60_000;
105
+
106
+ /**
107
+ * Is the credential **usable**, not merely present?
108
+ *
109
+ * Presence alone is a poor proxy: a key that has been disconnected, rotated or
110
+ * revoked still sits in the environment until the function is redeployed, so the
111
+ * store would advertise card payment and only discover the truth when a customer
112
+ * tries to pay. This asks Stripe (`GET /balance`, the cheapest authenticated
113
+ * call) and caches the answer for a minute so hot paths don't pay for it.
114
+ *
115
+ * A **rejected** credential means not connected. A *network* failure is not
116
+ * allowed to flip a working store to "no payments" on a blip: the last good
117
+ * answer stands until the cache expires.
118
+ */
119
+ export async function verifyStripeCredential(sr: any): Promise<CredentialCheck> {
120
+ const conn = await stripeConnection(sr);
121
+ if (!conn.connected) return { ok: false, rejected: true, error: conn.error };
122
+ const token = conn.accessToken!;
123
+
124
+ const cached = credentialCache;
125
+ if (cached && cached.token === token && Date.now() - cached.at < CREDENTIAL_TTL_MS) {
126
+ return cached.result;
127
+ }
128
+
129
+ let result: CredentialCheck;
130
+ try {
131
+ const res = await fetch(`${STRIPE_API}/balance`, { headers: { Authorization: `Bearer ${token}` } });
132
+ if (res.ok) {
133
+ result = { ok: true, rejected: false };
134
+ } else {
135
+ const body = await res.json().catch(() => ({}));
136
+ const rejected = res.status === 401 || res.status === 403;
137
+ result = {
138
+ ok: false,
139
+ rejected,
140
+ error: body?.error?.message ?? `The payment provider rejected the credential (${res.status}).`,
141
+ };
142
+ }
143
+ } catch (e) {
144
+ if (cached?.token === token && cached.result.ok) return cached.result; // transient
145
+ result = { ok: false, rejected: false, error: (e as Error)?.message ?? "Could not reach the payment provider." };
146
+ }
147
+
148
+ credentialCache = { token, at: Date.now(), result };
149
+ return result;
150
+ }
151
+
152
+ /** The token, or a 503 that says what the operator has to do. */
153
+ async function requireToken(sr: any): Promise<string> {
154
+ const conn = await stripeConnection(sr);
155
+ if (!conn.connected) {
156
+ throw new HttpError(
157
+ 503,
158
+ "Card payments are unavailable: no payment provider is set up for this app.",
159
+ "payment_provider_unavailable",
160
+ { provider: "stripe", reason: conn.error },
161
+ );
162
+ }
163
+ return conn.accessToken!;
164
+ }
165
+
166
+ /**
167
+ * One Stripe REST call. `form` is flattened into `application/x-www-form-urlencoded`
168
+ * (Stripe's wire format), supporting the nested `a[b][0][c]` shape it expects.
169
+ * Stripe errors surface as HttpError with Stripe's own message.
170
+ */
171
+ export async function stripeRequest(
172
+ sr: any,
173
+ method: "GET" | "POST",
174
+ path: string,
175
+ form?: Record<string, unknown>,
176
+ opts: { idempotencyKey?: string } = {},
177
+ ): Promise<any> {
178
+ const token = await requireToken(sr);
179
+ const body = form ? encodeForm(form) : undefined;
180
+
181
+ const send = (idempotencyKey?: string) => fetch(`${STRIPE_API}${path}`, {
182
+ method,
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ ...(body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
186
+ ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
187
+ },
188
+ body,
189
+ });
190
+
191
+ let res = await send(opts.idempotencyKey);
192
+ let payload = await res.json().catch(() => ({}));
193
+
194
+ // An idempotency key that Stripe has seen with *different* parameters is a
195
+ // hard error ("Keys for idempotent requests can only be used with the same
196
+ // parameters…") and it sticks for 24h. Idempotency is an optimisation here —
197
+ // it stops a double-click creating two payment pages — so a clash must not
198
+ // cost the operator a working payment link: retry once without the key.
199
+ if (!res.ok && opts.idempotencyKey && payload?.error?.type === "idempotency_error") {
200
+ console.warn(`stripe: idempotency clash on ${path} (${opts.idempotencyKey}) — retrying without a key`);
201
+ res = await send(undefined);
202
+ payload = await res.json().catch(() => ({}));
203
+ }
204
+
205
+ if (!res.ok) {
206
+ const message = payload?.error?.message ?? `Stripe request failed (${res.status}).`;
207
+ throw new HttpError(502, message, payload?.error?.code ?? "stripe_error", {
208
+ provider: "stripe",
209
+ stripe_type: payload?.error?.type,
210
+ });
211
+ }
212
+ return payload;
213
+ }
214
+
215
+ /** Stripe's bracket form encoding: { a: { b: [1] } } → a[b][0]=1 */
216
+ export function encodeForm(form: Record<string, unknown>): string {
217
+ const params = new URLSearchParams();
218
+ const walk = (prefix: string, value: unknown) => {
219
+ if (value === undefined || value === null || value === "") return;
220
+ if (Array.isArray(value)) {
221
+ value.forEach((v, i) => walk(`${prefix}[${i}]`, v));
222
+ } else if (typeof value === "object") {
223
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) walk(`${prefix}[${k}]`, v);
224
+ } else {
225
+ params.append(prefix, String(value));
226
+ }
227
+ };
228
+ for (const [key, value] of Object.entries(form)) walk(key, value);
229
+ return params.toString();
230
+ }
231
+
232
+ /**
233
+ * Short stable fingerprint of a request body, for idempotency keys.
234
+ *
235
+ * An idempotency key must map 1:1 to a set of parameters — Stripe rejects reusing
236
+ * one with *different* parameters ("Keys for idempotent requests can only be used
237
+ * with the same parameters…"). Keying on the order id plus its total was wrong:
238
+ * editing an order without moving the total (swapped items, a corrected email)
239
+ * kept the key while changing the request. Hashing the request itself makes the
240
+ * key change exactly when the request does — so a retry or double-click still
241
+ * dedupes, and an edited order gets a fresh session.
242
+ */
243
+ export async function requestFingerprint(form: Record<string, unknown>): Promise<string> {
244
+ const encoded = new TextEncoder().encode(encodeForm(form));
245
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
246
+ return [...new Uint8Array(digest)].slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join("");
247
+ }
248
+
249
+ // ── Checkout Session ─────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * Line items for the Checkout Session. One Stripe line per order line keeps the
253
+ * hosted page recognisable, and a single "Shipping, tax & fees" line carries
254
+ * everything else so the Stripe total always equals `order.total` exactly —
255
+ * which is what the order, the emails and the refund maths all use.
256
+ */
257
+ export function sessionLineItems(order: any): Array<Record<string, unknown>> {
258
+ const currency = String(order.currency || "USD").toLowerCase();
259
+ const lines: Array<Record<string, unknown>> = [];
260
+ let accountedFor = 0;
261
+
262
+ for (const line of order.line_items ?? []) {
263
+ const qty = Math.max(1, Number(line.quantity) || 1);
264
+ // Stripe multiplies unit_amount by quantity, so send the discounted unit price.
265
+ const unit = round2((Number(line.total) || 0) / qty);
266
+ if (unit <= 0) continue;
267
+ accountedFor = round2(accountedFor + unit * qty);
268
+ lines.push({
269
+ quantity: qty,
270
+ price_data: {
271
+ currency,
272
+ unit_amount: toMinorUnits(unit, currency),
273
+ product_data: {
274
+ name: String(line.name || "Item").slice(0, 250),
275
+ ...(line.sku ? { description: `SKU ${line.sku}` } : {}),
276
+ },
277
+ },
278
+ });
279
+ }
280
+
281
+ const remainder = round2((Number(order.total) || 0) - accountedFor);
282
+ if (remainder > 0) {
283
+ lines.push({
284
+ quantity: 1,
285
+ price_data: {
286
+ currency,
287
+ unit_amount: toMinorUnits(remainder, currency),
288
+ product_data: { name: "Shipping, tax & fees" },
289
+ },
290
+ });
291
+ }
292
+
293
+ // Rounding drift (or an order that is entirely discounts) — fall back to one
294
+ // line for the whole total rather than sending Stripe a different number.
295
+ if (!lines.length || remainder < 0) {
296
+ return [{
297
+ quantity: 1,
298
+ price_data: {
299
+ currency,
300
+ unit_amount: toMinorUnits(order.total ?? 0, currency),
301
+ product_data: { name: `Order #${order.order_number ?? order.id}` },
302
+ },
303
+ }];
304
+ }
305
+ return lines;
306
+ }
307
+
308
+ export interface CheckoutSession {
309
+ id: string;
310
+ url: string;
311
+ status: string;
312
+ payment_status: string;
313
+ payment_intent: string;
314
+ expires_at?: number;
315
+ /** Read back from the session's own metadata — Stripe's record, not a caller's claim. */
316
+ order_id: string;
317
+ order_key: string;
318
+ }
319
+
320
+ function toCheckoutSession(session: any): CheckoutSession {
321
+ const metadata = session.metadata ?? {};
322
+ return {
323
+ id: String(session.id),
324
+ url: String(session.url ?? ""),
325
+ status: String(session.status ?? ""),
326
+ payment_status: String(session.payment_status ?? ""),
327
+ payment_intent: typeof session.payment_intent === "string"
328
+ ? session.payment_intent
329
+ : String(session.payment_intent?.id ?? ""),
330
+ expires_at: session.expires_at,
331
+ order_id: String(metadata.order_id ?? ""),
332
+ order_key: String(metadata.order_key ?? ""),
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Create a Stripe Checkout Session for an order — the hosted payment page. The
338
+ * same call powers the storefront redirect and the admin's "payment link", so
339
+ * there is one payment flow to reason about, and no card data ever touches this
340
+ * app.
341
+ *
342
+ * `order_id` + `order_key` travel in the session metadata so the webhook and the
343
+ * return handler can find (and authorize) the order without trusting the URL.
344
+ */
345
+ export async function createCheckoutSession(sr: any, order: any, opts: {
346
+ successUrl: string;
347
+ cancelUrl: string;
348
+ customerEmail?: string;
349
+ locale?: string;
350
+ }): Promise<CheckoutSession> {
351
+ const form: Record<string, unknown> = {
352
+ mode: "payment",
353
+ line_items: sessionLineItems(order),
354
+ success_url: opts.successUrl,
355
+ cancel_url: opts.cancelUrl,
356
+ client_reference_id: String(order.id),
357
+ ...(opts.customerEmail ? { customer_email: opts.customerEmail } : {}),
358
+ metadata: {
359
+ order_id: String(order.id),
360
+ order_key: String(order.order_key ?? ""),
361
+ order_number: String(order.order_number ?? ""),
362
+ },
363
+ payment_intent_data: {
364
+ metadata: { order_id: String(order.id), order_key: String(order.order_key ?? "") },
365
+ },
366
+ };
367
+ const session = await stripeRequest(sr, "POST", "/checkout/sessions", form, {
368
+ idempotencyKey: `order-${order.id}-${await requestFingerprint(form)}`,
369
+ });
370
+
371
+ return toCheckoutSession(session);
372
+ }
373
+
374
+ /**
375
+ * Expire a Checkout Session so its link stops working. Used when a new session is
376
+ * minted for an order — otherwise an older link would still be payable, at the
377
+ * total the order had when that link was made.
378
+ *
379
+ * Best-effort by design: a session that is already completed or expired answers
380
+ * an error, which is not a problem worth surfacing.
381
+ */
382
+ export async function expireCheckoutSession(sr: any, sessionId: string): Promise<boolean> {
383
+ if (!sessionId) return false;
384
+ try {
385
+ await stripeRequest(sr, "POST", `/checkout/sessions/${encodeURIComponent(sessionId)}/expire`);
386
+ return true;
387
+ } catch {
388
+ return false;
389
+ }
390
+ }
391
+
392
+ export async function retrieveCheckoutSession(sr: any, sessionId: string): Promise<CheckoutSession> {
393
+ const session = await stripeRequest(sr, "GET", `/checkout/sessions/${encodeURIComponent(sessionId)}`);
394
+ return toCheckoutSession(session);
395
+ }
396
+
397
+ /** Refund a captured payment (full or partial). `amount` is in display units. */
398
+ export async function createStripeRefund(sr: any, opts: {
399
+ paymentIntentId: string;
400
+ amount: number;
401
+ currency: string;
402
+ reason?: string;
403
+ idempotencyKey?: string;
404
+ }): Promise<{ id: string; status: string; amount: number }> {
405
+ const refund = await stripeRequest(sr, "POST", "/refunds", {
406
+ payment_intent: opts.paymentIntentId,
407
+ amount: toMinorUnits(opts.amount, opts.currency),
408
+ ...(opts.reason ? { metadata: { reason: String(opts.reason).slice(0, 500) } } : {}),
409
+ }, { idempotencyKey: opts.idempotencyKey });
410
+ return {
411
+ id: String(refund.id),
412
+ status: String(refund.status ?? ""),
413
+ amount: fromMinorUnits(refund.amount ?? 0, opts.currency),
414
+ };
415
+ }
416
+
417
+ // ── webhook signature ────────────────────────────────────────────────────────
418
+
419
+ /**
420
+ * Verify a Stripe webhook signature (`Stripe-Signature: t=…,v1=…`) against the
421
+ * raw request body. Constant-time comparison, and a timestamp tolerance so a
422
+ * captured request can't be replayed indefinitely.
423
+ *
424
+ * Returns false — never throws — so the caller answers 400 without leaking why.
425
+ */
426
+ export async function verifyWebhookSignature(opts: {
427
+ payload: string;
428
+ header: string;
429
+ secret: string;
430
+ toleranceSeconds?: number;
431
+ nowSeconds?: number;
432
+ }): Promise<boolean> {
433
+ const { payload, header, secret } = opts;
434
+ if (!payload || !header || !secret) return false;
435
+
436
+ const parts = header.split(",").map((p) => p.trim().split("="));
437
+ const timestamp = parts.find((p) => p[0] === "t")?.[1] ?? "";
438
+ const signatures = parts.filter((p) => p[0] === "v1").map((p) => p[1]);
439
+ if (!timestamp || !signatures.length) return false;
440
+
441
+ const tolerance = opts.toleranceSeconds ?? 300;
442
+ const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
443
+ const age = now - Number(timestamp);
444
+ if (!Number.isFinite(age) || Math.abs(age) > tolerance) return false;
445
+
446
+ const key = await crypto.subtle.importKey(
447
+ "raw",
448
+ new TextEncoder().encode(secret),
449
+ { name: "HMAC", hash: "SHA-256" },
450
+ false,
451
+ ["sign"],
452
+ );
453
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`));
454
+ const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
455
+ return signatures.some((sig) => timingSafeEqual(sig, expected));
456
+ }
457
+
458
+ function timingSafeEqual(a: string, b: string): boolean {
459
+ if (a.length !== b.length) return false;
460
+ let diff = 0;
461
+ for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
462
+ return diff === 0;
463
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tax-rate matching and application, following standard commerce tax semantics:
3
+ * - rates match by tax class + location (empty field = wildcard)
4
+ * - rates sort by priority asc; ONE rate applies per priority level
5
+ * - non-compound rates apply on the base amount; compound rates apply on
6
+ * (base + all previously accumulated tax), in priority order
7
+ */
8
+ import { round2 } from "./money.ts";
9
+
10
+ export interface TaxAddress {
11
+ country?: string;
12
+ state?: string;
13
+ postcode?: string;
14
+ city?: string;
15
+ }
16
+
17
+ /** Does `postcode` match `pattern`? Supports exact, `90*` wildcard, `1000...2000` numeric range. */
18
+ export function postcodeMatchesPattern(pattern: string, postcode: string): boolean {
19
+ const p = (pattern || "").trim().toUpperCase();
20
+ const pc = (postcode || "").trim().toUpperCase();
21
+ if (!p) return true; // empty pattern = wildcard
22
+ if (!pc) return false;
23
+ if (p.includes("...")) {
24
+ const [lo, hi] = p.split("...").map((s) => parseInt(s.trim(), 10));
25
+ const n = parseInt(pc, 10);
26
+ return !Number.isNaN(lo) && !Number.isNaN(hi) && !Number.isNaN(n) && n >= lo && n <= hi;
27
+ }
28
+ if (p.includes("*")) {
29
+ const re = new RegExp("^" + p.split("*").map(escapeRegex).join(".*") + "$");
30
+ return re.test(pc);
31
+ }
32
+ return p === pc;
33
+ }
34
+
35
+ function escapeRegex(s: string): string {
36
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ function rateMatchesLocation(rate: any, addr: TaxAddress): boolean {
40
+ const country = (addr.country || "").toUpperCase();
41
+ const state = (addr.state || "").toUpperCase();
42
+ const city = (addr.city || "").toLowerCase();
43
+ if (rate.country && rate.country.toUpperCase() !== country) return false;
44
+ if (rate.state && rate.state.toUpperCase() !== state) return false;
45
+ const postcodes: string[] = rate.postcodes || [];
46
+ if (postcodes.length && !postcodes.some((p: string) => postcodeMatchesPattern(p, addr.postcode || ""))) return false;
47
+ const cities: string[] = rate.cities || [];
48
+ if (cities.length && !cities.some((c: string) => (c || "").toLowerCase() === city)) return false;
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Find the applicable rates for a tax class at an address.
54
+ * Sorted by priority asc (menu_order breaks ties); one rate per priority.
55
+ */
56
+ export function matchTaxRates(allRates: any[], addr: TaxAddress, taxClass: string): any[] {
57
+ const cls = taxClass || "standard";
58
+ const candidates = (allRates || [])
59
+ .filter((r) => (r.tax_class || "standard") === cls && rateMatchesLocation(r, addr))
60
+ .sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1) || (a.menu_order ?? 0) - (b.menu_order ?? 0));
61
+ // only the first matching rate per priority level applies
62
+ const byPriority = new Map<number, any>();
63
+ for (const r of candidates) {
64
+ const p = r.priority ?? 1;
65
+ if (!byPriority.has(p)) byPriority.set(p, r);
66
+ }
67
+ return [...byPriority.values()];
68
+ }
69
+
70
+ export interface AppliedRate {
71
+ rate: any;
72
+ amount: number;
73
+ }
74
+
75
+ /**
76
+ * Apply matched rates (already priority-deduped, priority-ordered) to an
77
+ * ex-tax amount. Non-compound rates each apply on the base; compound rates
78
+ * apply on base + accumulated tax so far.
79
+ */
80
+ export function applyRates(amount: number, rates: any[]): AppliedRate[] {
81
+ const base = Number(amount) || 0;
82
+ const out: AppliedRate[] = [];
83
+ let accumulated = 0;
84
+ for (const rate of rates || []) {
85
+ const pct = (Number(rate.rate) || 0) / 100;
86
+ const taxable = rate.compound ? base + accumulated : base;
87
+ const tax = round2(taxable * pct);
88
+ accumulated += tax;
89
+ out.push({ rate, amount: tax });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export interface InclusiveTaxResult {
95
+ net: number;
96
+ taxes: AppliedRate[];
97
+ totalTax: number;
98
+ }
99
+
100
+ /**
101
+ * Extract tax from a tax-INCLUSIVE gross amount (prices_include_tax mode),
102
+ * mirroring standard inclusive-tax extraction: compound rates are unwound from the
103
+ * outside in (last priority first), then the remaining amount is split across
104
+ * the non-compound rates which all share the same net base.
105
+ */
106
+ export function extractInclusiveTax(gross: number, rates: any[]): InclusiveTaxResult {
107
+ let remaining = Number(gross) || 0;
108
+ const taxesByRate = new Map<any, number>();
109
+
110
+ // 1) unwind compound rates, highest priority (applied last) first
111
+ const compound = (rates || []).filter((r) => r.compound).reverse();
112
+ for (const rate of compound) {
113
+ const pct = (Number(rate.rate) || 0) / 100;
114
+ const tax = round2(remaining - remaining / (1 + pct));
115
+ taxesByRate.set(rate, tax);
116
+ remaining -= tax;
117
+ }
118
+
119
+ // 2) the non-compound rates all apply on the same net base
120
+ const regular = (rates || []).filter((r) => !r.compound);
121
+ const regularSum = regular.reduce((a, r) => a + (Number(r.rate) || 0) / 100, 0);
122
+ const net = regularSum > 0 ? remaining / (1 + regularSum) : remaining;
123
+ for (const rate of regular) {
124
+ const pct = (Number(rate.rate) || 0) / 100;
125
+ taxesByRate.set(rate, round2(net * pct));
126
+ }
127
+
128
+ const taxes: AppliedRate[] = (rates || []).map((rate) => ({ rate, amount: taxesByRate.get(rate) ?? 0 }));
129
+ const totalTax = round2(taxes.reduce((a, t) => a + t.amount, 0));
130
+ return { net: round2((Number(gross) || 0) - totalTax), taxes, totalTax };
131
+ }
132
+
133
+ /** Sum of applied-rate amounts, rounded. */
134
+ export function sumTax(applied: AppliedRate[]): number {
135
+ return round2((applied || []).reduce((a, t) => a + t.amount, 0));
136
+ }