@base44/app-plugin-commerce 0.1.6 → 0.1.8
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.
- package/README.md +12 -12
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +68 -46
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +60 -39
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -166
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +5 -6
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- 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
|
-
}
|