@base44/app-plugin-commerce 0.1.5 → 0.1.7

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 (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +84 -31
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +76 -41
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -191
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -1,463 +0,0 @@
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
- }
@@ -1,27 +0,0 @@
1
- import useAsync from "./useAsync";
2
- import { call } from "../lib/api";
3
-
4
- /**
5
- * Whether the store can take an online payment right now.
6
- *
7
- * Availability is a **live fact about the connected provider**, never a stored
8
- * flag, so every payment affordance in the admin derives from this instead of
9
- * hardcoding a "not wired" state. Which provider is wired is a backend concern
10
- * (`base44/shared/commerce/payments.ts`); the UI only needs `connected`, and
11
- * shows "No payment provider connected" rather than naming one.
12
- *
13
- * @returns {{connected: boolean, gatewaySlug: string, provider: string,
14
- * providerLabel: string, loading: boolean, refetch: Function}}
15
- */
16
- export default function usePaymentProvider() {
17
- const status = useAsync(() => call("admin-tools", "payment-connector-status", {}, { silent: true }), []);
18
- return {
19
- connected: !!status.data?.connected,
20
- // "" until the backend answers — no provider name is hardcoded here.
21
- gatewaySlug: status.data?.gateway_slug ?? "",
22
- provider: status.data?.provider ?? "",
23
- providerLabel: status.data?.provider_label ?? "",
24
- loading: status.loading,
25
- refetch: status.refetch,
26
- };
27
- }
@@ -1,118 +0,0 @@
1
- import React from "react";
2
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3
- import { Label } from "@/components/ui/label";
4
- import { Switch } from "@/components/ui/switch";
5
- import {
6
- Select,
7
- SelectContent,
8
- SelectItem,
9
- SelectTrigger,
10
- SelectValue,
11
- } from "@/components/ui/select";
12
- import { WEIGHT_UNITS, DIMENSION_UNITS } from "../../lib/constants";
13
- import useGroupForm from "./useGroupForm";
14
-
15
- const DEFAULTS = {
16
- weight_unit: "kg",
17
- dimension_unit: "cm",
18
- enable_reviews: true,
19
- review_rating_required: true,
20
- only_verified_reviews: false,
21
- auto_approve_reviews: false,
22
- };
23
-
24
- function SwitchRow({ label, hint, checked, onChange }) {
25
- return (
26
- <div className="flex items-start justify-between gap-4">
27
- <div>
28
- <Label>{label}</Label>
29
- {hint && <p className="text-xs text-muted-foreground">{hint}</p>}
30
- </div>
31
- <Switch checked={!!checked} onCheckedChange={onChange} />
32
- </div>
33
- );
34
- }
35
-
36
- export default function ProductsSettings() {
37
- const { form, setField, SaveButton } = useGroupForm("products", DEFAULTS);
38
-
39
- return (
40
- <div className="max-w-3xl space-y-4">
41
- <Card>
42
- <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
43
- <div className="space-y-1.5">
44
- <CardTitle>Measurements</CardTitle>
45
- <CardDescription>Units used for product weight and dimensions.</CardDescription>
46
- </div>
47
- <SaveButton />
48
- </CardHeader>
49
- <CardContent className="grid gap-4 sm:grid-cols-2">
50
- <div className="space-y-1.5">
51
- <Label>Weight unit</Label>
52
- <Select value={form.weight_unit} onValueChange={(v) => setField("weight_unit", v)}>
53
- <SelectTrigger>
54
- <SelectValue />
55
- </SelectTrigger>
56
- <SelectContent>
57
- {WEIGHT_UNITS.map((u) => (
58
- <SelectItem key={u} value={u}>
59
- {u}
60
- </SelectItem>
61
- ))}
62
- </SelectContent>
63
- </Select>
64
- </div>
65
- <div className="space-y-1.5">
66
- <Label>Dimensions unit</Label>
67
- <Select value={form.dimension_unit} onValueChange={(v) => setField("dimension_unit", v)}>
68
- <SelectTrigger>
69
- <SelectValue />
70
- </SelectTrigger>
71
- <SelectContent>
72
- {DIMENSION_UNITS.map((u) => (
73
- <SelectItem key={u} value={u}>
74
- {u}
75
- </SelectItem>
76
- ))}
77
- </SelectContent>
78
- </Select>
79
- </div>
80
- </CardContent>
81
- </Card>
82
-
83
- <Card>
84
- <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
85
- <div className="space-y-1.5">
86
- <CardTitle>Reviews</CardTitle>
87
- </div>
88
- <SaveButton />
89
- </CardHeader>
90
- <CardContent className="space-y-4">
91
- <SwitchRow
92
- label="Enable product reviews"
93
- checked={form.enable_reviews}
94
- onChange={(v) => setField("enable_reviews", v)}
95
- />
96
- <SwitchRow
97
- label="Star rating required"
98
- hint="Reviews submitted without a rating are rejected."
99
- checked={form.review_rating_required}
100
- onChange={(v) => setField("review_rating_required", v)}
101
- />
102
- <SwitchRow
103
- label="Reviews can only be left by verified owners"
104
- hint="Requires a completed order containing the product for the reviewer's email."
105
- checked={form.only_verified_reviews}
106
- onChange={(v) => setField("only_verified_reviews", v)}
107
- />
108
- <SwitchRow
109
- label="Auto-approve reviews"
110
- hint="When off, new reviews are held for moderation."
111
- checked={form.auto_approve_reviews}
112
- onChange={(v) => setField("auto_approve_reviews", v)}
113
- />
114
- </CardContent>
115
- </Card>
116
- </div>
117
- );
118
- }