@gigamusic/checkout 4.3.2 → 4.5.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.
package/src/fulfill.ts ADDED
@@ -0,0 +1,266 @@
1
+ import type Stripe from "stripe";
2
+ import { signOrderToken } from "@gigamusic/core";
3
+ import {
4
+ isUniqueConstraintError,
5
+ type OrderWithItems,
6
+ type ReleaseWithTracks,
7
+ type TrackWithFiles,
8
+ } from "@gigamusic/db";
9
+ import { renderPurchaseConfirmation } from "@gigamusic/email";
10
+ import type { FulfillSessionDeps, PurchaseConfirmationDeps } from "./types.js";
11
+
12
+ export type FulfillSessionResult =
13
+ /** This call wrote the order row. The caller owns sending the confirmation email. */
14
+ | {
15
+ status: "created";
16
+ order: OrderWithItems;
17
+ itemNames: string[];
18
+ customerEmail: string;
19
+ totalCents: number;
20
+ }
21
+ /**
22
+ * The order was already on disk — either the pre-flight lookup found it or a
23
+ * concurrent writer won the unique-constraint race. `order` is null only if
24
+ * the re-read after a lost race somehow came back empty.
25
+ */
26
+ | { status: "already-recorded"; order: OrderWithItems | null }
27
+ /** Nothing was written and nothing is owed. */
28
+ | { status: "skipped"; reason: "site-mismatch" | "no-items" | "unpaid" };
29
+
30
+ /**
31
+ * Turn a completed Stripe Checkout Session into a persisted order, idempotently.
32
+ *
33
+ * This is the single fulfillment path shared by the Stripe webhook and any
34
+ * consumer that wants to fulfill synchronously — e.g. a post-checkout success
35
+ * page that would otherwise have to wait on webhook delivery to show download
36
+ * links. Whoever calls first writes the row; everyone after gets
37
+ * `already-recorded`. That guarantee rests on the unique constraint over
38
+ * `stripeSessionId` inside `recordCompletedOrder`, not on call ordering, so
39
+ * concurrent callers are safe.
40
+ *
41
+ * Only `status: "created"` should trigger side effects (the confirmation
42
+ * email) — that status is returned to exactly one caller per session.
43
+ *
44
+ * ### Published-only resolution (gotcha)
45
+ *
46
+ * Release/track IDs from `session.metadata` are resolved against
47
+ * `queries.listPublishedReleases()`. Anything not in the published catalog at
48
+ * fulfillment time is silently dropped from the persisted order's line items —
49
+ * the customer is still charged, but the order row doesn't include the
50
+ * unpublished item.
51
+ *
52
+ * In the canonical flow this can't happen: `createCheckoutHandler` filters on
53
+ * the same `listPublishedReleases()` before it ever creates the Stripe
54
+ * Session. The only ways an unpublished ID can reach here are:
55
+ * - an admin unpublishes between session-create and fulfillment
56
+ * (a several-second race window),
57
+ * - a consumer bypasses `createCheckoutHandler` and creates Stripe sessions
58
+ * directly with their own metadata.
59
+ *
60
+ * If either is a realistic concern, swap the resolution call here to
61
+ * `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be aware
62
+ * that doing so will surface unpublished items in admin order views.
63
+ */
64
+ export async function fulfillCheckoutSession(
65
+ session: Stripe.Checkout.Session,
66
+ deps: FulfillSessionDeps,
67
+ ): Promise<FulfillSessionResult> {
68
+ const { queries, site } = deps;
69
+
70
+ // One Stripe account fans every event out to every registered endpoint, and
71
+ // a success URL can be hand-forged. Ignore sessions belonging to a different
72
+ // site so we never record a foreign purchase (with zero matched line items)
73
+ // under this one.
74
+ if (session.metadata?.site !== site) {
75
+ return { status: "skipped", reason: "site-mismatch" };
76
+ }
77
+
78
+ // Delayed-notification methods (bank debits, vouchers) complete checkout with
79
+ // the money still in flight; the session only flips to `paid` later, via
80
+ // `checkout.session.async_payment_succeeded`. Handing over downloads at that
81
+ // point would be giving away unpaid goods. `no_payment_required` (fully
82
+ // discounted) and legacy sessions with no `payment_status` are fulfillable.
83
+ if (session.payment_status === "unpaid") {
84
+ return { status: "skipped", reason: "unpaid" };
85
+ }
86
+
87
+ const existing = await queries.getOrderByStripeSessionId(session.id);
88
+ if (existing) {
89
+ return { status: "already-recorded", order: existing };
90
+ }
91
+
92
+ const releaseIds = safeJsonIdList(session.metadata?.release_ids);
93
+ const trackIds = safeJsonIdList(session.metadata?.track_ids);
94
+ // Per-line amounts the customer actually paid, keyed `r<id>` / `t<id>`,
95
+ // stamped by `createCheckoutHandler`. Absent on pre-upgrade sessions and on
96
+ // catalog purchases — those fall back to the catalog price below.
97
+ const chargedAmounts = safeAmountMap(session.metadata?.amounts);
98
+
99
+ if (releaseIds.length === 0 && trackIds.length === 0) {
100
+ return { status: "skipped", reason: "no-items" };
101
+ }
102
+
103
+ // Snapshot the published catalog once so we can resolve both release and
104
+ // track prices + names off a single in-memory lookup.
105
+ const allReleases = await queries.listPublishedReleases();
106
+ const releaseById = new Map(allReleases.map((r) => [r.id, r]));
107
+ const trackContext = new Map<
108
+ number,
109
+ { track: TrackWithFiles; release: ReleaseWithTracks }
110
+ >();
111
+ for (const release of allReleases) {
112
+ for (const track of release.tracks) {
113
+ trackContext.set(track.id, { track, release });
114
+ }
115
+ }
116
+
117
+ const matchedReleases = releaseIds
118
+ .map((id) => releaseById.get(id))
119
+ .filter((r): r is ReleaseWithTracks => r !== undefined);
120
+ const matchedTracks = trackIds
121
+ .map((id) => trackContext.get(id))
122
+ .filter(
123
+ (c): c is { track: TrackWithFiles; release: ReleaseWithTracks } => c !== undefined,
124
+ );
125
+
126
+ const orderItems = [
127
+ ...matchedReleases.map((r) => ({
128
+ releaseId: r.id,
129
+ price: chargedAmounts[`r${r.id}`] ?? r.price,
130
+ })),
131
+ ...matchedTracks.map(({ track }) => ({
132
+ trackId: track.id,
133
+ price: chargedAmounts[`t${track.id}`] ?? track.price,
134
+ })),
135
+ ];
136
+
137
+ const customerEmail = session.customer_details?.email ?? "";
138
+ const totalCents = session.amount_total ?? 0;
139
+
140
+ let order: OrderWithItems;
141
+ try {
142
+ order = await queries.recordCompletedOrder({
143
+ stripeSessionId: session.id,
144
+ stripePaymentId:
145
+ typeof session.payment_intent === "string" ? session.payment_intent : null,
146
+ email: customerEmail,
147
+ amountTotal: totalCents,
148
+ items: orderItems,
149
+ });
150
+ } catch (err) {
151
+ // Two callers can both pass the pre-flight lookup and race here — a webhook
152
+ // retry, or the success page rendering while the webhook is mid-flight. The
153
+ // unique constraint on `stripeSessionId` guarantees only one row lands; the
154
+ // loser surfaces as a 23505 unique-violation. Re-read so the loser still
155
+ // gets the order to render, but without claiming the "created" side effects.
156
+ if (isUniqueConstraintError(err)) {
157
+ return {
158
+ status: "already-recorded",
159
+ order: (await queries.getOrderByStripeSessionId(session.id)) ?? null,
160
+ };
161
+ }
162
+ throw err;
163
+ }
164
+
165
+ const itemNames = [
166
+ ...matchedReleases.map((r) => r.name),
167
+ ...matchedTracks.map(({ track, release }) => `${release.name} — ${track.name}`),
168
+ ];
169
+
170
+ return { status: "created", order, itemNames, customerEmail, totalCents };
171
+ }
172
+
173
+ /**
174
+ * Render and send the post-purchase confirmation email carrying the durable
175
+ * magic link back to the customer's downloads.
176
+ *
177
+ * Errors are logged, never thrown: the order is already recorded and paid for,
178
+ * so a mail-provider hiccup must not fail the caller (a webhook returning 500
179
+ * would make Stripe retry a fulfillment that already succeeded). Callers
180
+ * typically wrap this in `after()` so the response isn't blocked on it.
181
+ */
182
+ export async function sendPurchaseConfirmation(
183
+ args: {
184
+ orderId: number;
185
+ customerEmail: string;
186
+ itemNames: string[];
187
+ totalCents: number;
188
+ },
189
+ deps: PurchaseConfirmationDeps,
190
+ ): Promise<void> {
191
+ const {
192
+ email,
193
+ branding,
194
+ emailFrom,
195
+ baseUrl,
196
+ orderTokenSecret,
197
+ orderTokenExpiry = null,
198
+ verifyPath = "/orders/verify",
199
+ renderConfirmation = renderPurchaseConfirmation,
200
+ } = deps;
201
+
202
+ if (!args.customerEmail) {
203
+ // Stripe almost always supplies an email but doesn't guarantee it. The
204
+ // order is safely recorded; manual intervention required to deliver the
205
+ // magic link.
206
+ console.warn(
207
+ `[gigamusic/checkout] order ${args.orderId} has no customer email — recorded, but the customer cannot retrieve downloads.`,
208
+ );
209
+ return;
210
+ }
211
+
212
+ try {
213
+ const verifyToken = await signOrderToken({
214
+ orderId: String(args.orderId),
215
+ email: args.customerEmail,
216
+ secret: orderTokenSecret,
217
+ expiresIn: orderTokenExpiry,
218
+ });
219
+ const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
220
+ const { subject, html } = await renderConfirmation({
221
+ branding,
222
+ verifyUrl,
223
+ itemNames: args.itemNames,
224
+ totalCents: args.totalCents,
225
+ });
226
+ await email.send({ from: emailFrom, to: args.customerEmail, subject, html });
227
+ } catch (err) {
228
+ console.error(
229
+ `[gigamusic/checkout] failed to send purchase confirmation to ${args.customerEmail} for order ${args.orderId}:`,
230
+ err,
231
+ );
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Parse the JSON-stringified `{ "r<id>" | "t<id>": cents }` amount map from
237
+ * Stripe metadata, returning `{}` on any failure. Non-finite values are
238
+ * dropped so a malformed entry falls back to the catalog price at the call site.
239
+ */
240
+ function safeAmountMap(value: string | undefined | null): Record<string, number> {
241
+ if (!value) return {};
242
+ try {
243
+ const parsed: unknown = JSON.parse(value);
244
+ if (typeof parsed !== "object" || parsed === null) return {};
245
+ const out: Record<string, number> = {};
246
+ for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) {
247
+ const n = Number(raw);
248
+ if (Number.isFinite(n)) out[key] = n;
249
+ }
250
+ return out;
251
+ } catch {
252
+ return {};
253
+ }
254
+ }
255
+
256
+ /** Parse a JSON-stringified id array from Stripe metadata, returning `[]` on any failure. NaN entries are dropped. */
257
+ function safeJsonIdList(value: string | undefined | null): number[] {
258
+ if (!value) return [];
259
+ try {
260
+ const parsed: unknown = JSON.parse(value);
261
+ if (!Array.isArray(parsed)) return [];
262
+ return parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n));
263
+ } catch {
264
+ return [];
265
+ }
266
+ }
package/src/index.ts CHANGED
@@ -1,13 +1,18 @@
1
1
  export { createCheckoutHandler } from "./checkout.js";
2
2
  export { createStripeWebhookHandler } from "./webhook.js";
3
+ export { fulfillCheckoutSession, sendPurchaseConfirmation } from "./fulfill.js";
3
4
  export { createDownloadHandler } from "./download.js";
4
5
  export { createDownloadZipHandler } from "./zip.js";
5
6
  export { createDownloadZipStreamHandler } from "./zip-stream.js";
6
7
  export { createSwZipFallbackHandler } from "./sw-zip-fallback.js";
7
8
 
9
+ export type { FulfillSessionResult } from "./fulfill.js";
10
+
8
11
  export type {
9
12
  CheckoutCartItem,
10
13
  CheckoutDeps,
11
14
  DownloadDeps,
15
+ FulfillSessionDeps,
16
+ PurchaseConfirmationDeps,
12
17
  WebhookDeps,
13
18
  } from "./types.js";
package/src/types.ts CHANGED
@@ -60,23 +60,20 @@ export interface CheckoutDeps {
60
60
  | (() => Promise<{ totalCents: number; productName: string } | null | undefined>);
61
61
  }
62
62
 
63
- export interface WebhookDeps {
64
- /**
65
- * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
66
- * at the consumer-app boundary) or a `stripeSecret` string the handler uses
67
- * to construct one. Pass exactly one — `stripe` wins if both are provided.
68
- */
69
- stripe?: Stripe;
70
- stripeSecret?: string;
71
- webhookSecret: string;
63
+ /** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
64
+ export interface FulfillSessionDeps {
72
65
  queries: Queries;
73
66
  /**
74
- * The site (alias) this endpoint serves. Stripe broadcasts every event to
75
- * every registered endpoint, so the handler ignores any
76
- * `checkout.session.completed` whose `metadata.site` doesn't match — this is
67
+ * The site (alias) this fulfillment path serves. Stripe broadcasts every
68
+ * event to every registered endpoint (and a success URL can be hand-forged),
69
+ * so any session whose `metadata.site` doesn't match is skipped — this is
77
70
  * what lets one Stripe account safely back multiple sites.
78
71
  */
79
72
  site: string;
73
+ }
74
+
75
+ /** What `sendPurchaseConfirmation` needs to mail the customer their magic link. */
76
+ export interface PurchaseConfirmationDeps {
80
77
  email: EmailProvider;
81
78
  branding: EmailBranding;
82
79
  emailFrom: string;
@@ -104,6 +101,17 @@ export interface WebhookDeps {
104
101
  ) => RenderedEmail | Promise<RenderedEmail>;
105
102
  }
106
103
 
104
+ export interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
105
+ /**
106
+ * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
107
+ * at the consumer-app boundary) or a `stripeSecret` string the handler uses
108
+ * to construct one. Pass exactly one — `stripe` wins if both are provided.
109
+ */
110
+ stripe?: Stripe;
111
+ stripeSecret?: string;
112
+ webhookSecret: string;
113
+ }
114
+
107
115
  export interface DownloadDeps {
108
116
  queries: Queries;
109
117
  storage: StorageProvider;
package/src/webhook.ts CHANGED
@@ -1,58 +1,25 @@
1
1
  import { after, type NextRequest } from "next/server";
2
2
  import Stripe from "stripe";
3
- import { signOrderToken } from "@gigamusic/core";
4
- import { isUniqueConstraintError, type ReleaseWithTracks, type TrackWithFiles } from "@gigamusic/db";
5
- import { renderPurchaseConfirmation } from "@gigamusic/email";
3
+ import { fulfillCheckoutSession, sendPurchaseConfirmation } from "./fulfill.js";
6
4
  import type { WebhookDeps } from "./types.js";
7
5
 
8
6
  /**
9
7
  * Build the POST handler for Stripe's webhook endpoint. Verifies the
10
- * signature, records the order on `checkout.session.completed`, then defers
11
- * the magic-link email via `after()` so Stripe sees a fast 200.
8
+ * signature, records the order on `checkout.session.completed` via the shared
9
+ * `fulfillCheckoutSession`, then defers the magic-link email via `after()` so
10
+ * Stripe sees a fast 200.
12
11
  *
13
- * Idempotency is enforced two ways: a pre-flight `getOrderByStripeSessionId`
14
- * lookup, plus a unique-constraint catch around `recordCompletedOrder` for
15
- * the case where two webhook deliveries race past the lookup. Both paths
16
- * short-circuit with `{ received: true }` so Stripe doesn't retry.
17
- *
18
- * ### Published-only resolution (gotcha)
19
- *
20
- * The handler resolves the release/track IDs from `session.metadata` against
21
- * `queries.listPublishedReleases()`. Anything not in the published catalog at
22
- * the moment the webhook fires is silently dropped from the persisted order's
23
- * line items — the customer is still charged, but the order row doesn't
24
- * include the unpublished item.
25
- *
26
- * In the canonical flow this can't happen: `createCheckoutHandler` filters on
27
- * the same `listPublishedReleases()` before it ever creates the Stripe
28
- * Session. The only ways an unpublished ID can reach this handler are:
29
- * - an admin unpublishes between session-create and webhook-delivery
30
- * (a several-second race window),
31
- * - a consumer bypasses `createCheckoutHandler` and creates Stripe sessions
32
- * directly with their own metadata.
33
- *
34
- * If either of those is a realistic concern, swap the resolution call here
35
- * to `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be
36
- * aware that doing so will surface unpublished items in admin order views.
12
+ * The handler is not the only fulfillment path — a consumer's success page can
13
+ * call `fulfillCheckoutSession` directly rather than waiting on webhook
14
+ * delivery. Whichever runs first writes the row; the other gets
15
+ * `already-recorded` and skips the email, so the customer is mailed exactly
16
+ * once no matter who wins. Every non-`created` outcome answers Stripe with
17
+ * `{ received: true }` so it doesn't retry.
37
18
  */
38
19
  export function createStripeWebhookHandler(
39
20
  deps: WebhookDeps,
40
21
  ): (req: NextRequest) => Promise<Response> {
41
- const {
42
- stripe: stripeOverride,
43
- stripeSecret,
44
- webhookSecret,
45
- queries,
46
- site,
47
- email,
48
- branding,
49
- emailFrom,
50
- baseUrl,
51
- orderTokenSecret,
52
- orderTokenExpiry = null,
53
- verifyPath = "/orders/verify",
54
- renderConfirmation = renderPurchaseConfirmation,
55
- } = deps;
22
+ const { stripe: stripeOverride, stripeSecret, webhookSecret, queries, site } = deps;
56
23
 
57
24
  if (!stripeOverride && !stripeSecret) {
58
25
  throw new Error(
@@ -83,164 +50,23 @@ export function createStripeWebhookHandler(
83
50
 
84
51
  const session = event.data.object as Stripe.Checkout.Session;
85
52
 
86
- // One Stripe account fans every event out to every registered endpoint.
87
- // Ignore sessions that belong to a different site so we don't record a
88
- // foreign purchase (with zero matched line items) under this site.
89
- if (session.metadata?.site !== site) {
53
+ const result = await fulfillCheckoutSession(session, { queries, site });
54
+ if (result.status !== "created") {
90
55
  return Response.json({ received: true });
91
56
  }
92
57
 
93
- const existing = await queries.getOrderByStripeSessionId(session.id);
94
- if (existing) {
95
- return Response.json({ received: true });
96
- }
97
-
98
- const releaseIds = safeJsonIdList(session.metadata?.release_ids);
99
- const trackIds = safeJsonIdList(session.metadata?.track_ids);
100
- // Per-line amounts the customer actually paid, keyed `r<id>` / `t<id>`,
101
- // stamped by `createCheckoutHandler`. Absent on pre-upgrade sessions and
102
- // on catalog purchases — those fall back to the catalog price below.
103
- const chargedAmounts = safeAmountMap(session.metadata?.amounts);
104
-
105
- if (releaseIds.length === 0 && trackIds.length === 0) {
106
- return Response.json({ received: true });
107
- }
108
-
109
- // Snapshot the published catalog once so we can resolve both release and
110
- // track prices + names off a single in-memory lookup.
111
- const allReleases = await queries.listPublishedReleases();
112
- const releaseById = new Map(allReleases.map((r) => [r.id, r]));
113
- const trackContext = new Map<
114
- number,
115
- { track: TrackWithFiles; release: ReleaseWithTracks }
116
- >();
117
- for (const release of allReleases) {
118
- for (const track of release.tracks) {
119
- trackContext.set(track.id, { track, release });
120
- }
121
- }
122
-
123
- const matchedReleases = releaseIds
124
- .map((id) => releaseById.get(id))
125
- .filter((r): r is ReleaseWithTracks => r !== undefined);
126
- const matchedTracks = trackIds
127
- .map((id) => trackContext.get(id))
128
- .filter(
129
- (c): c is { track: TrackWithFiles; release: ReleaseWithTracks } =>
130
- c !== undefined,
131
- );
132
-
133
- const orderItems = [
134
- ...matchedReleases.map((r) => ({
135
- releaseId: r.id,
136
- price: chargedAmounts[`r${r.id}`] ?? r.price,
137
- })),
138
- ...matchedTracks.map(({ track }) => ({
139
- trackId: track.id,
140
- price: chargedAmounts[`t${track.id}`] ?? track.price,
141
- })),
142
- ];
143
-
144
- const customerEmail = session.customer_details?.email ?? "";
145
-
146
- let recordedOrder;
147
- try {
148
- recordedOrder = await queries.recordCompletedOrder({
149
- stripeSessionId: session.id,
150
- stripePaymentId:
151
- typeof session.payment_intent === "string" ? session.payment_intent : null,
152
- email: customerEmail,
153
- amountTotal: session.amount_total ?? 0,
154
- items: orderItems,
155
- });
156
- } catch (err) {
157
- // Two deliveries can both pass the pre-flight lookup and race here. The
158
- // unique constraint on `stripeSessionId` guarantees only one row lands;
159
- // the loser surfaces as a 23505 unique-violation. Treat that as the same
160
- // idempotent short-circuit — no email, no 500, no Stripe retry.
161
- if (isUniqueConstraintError(err)) {
162
- return Response.json({ received: true });
163
- }
164
- throw err;
165
- }
166
-
167
- if (!customerEmail) {
168
- // Stripe almost always supplies an email but doesn't guarantee it. The
169
- // order is safely recorded; manual intervention required to deliver the
170
- // magic link.
171
- console.warn(
172
- `[stripe-webhook] checkout.session.completed (session=${session.id}) has no customer_details.email — order recorded but customer cannot retrieve downloads.`,
173
- );
174
- return Response.json({ received: true });
175
- }
176
-
177
- const itemNames = [
178
- ...matchedReleases.map((r) => r.name),
179
- ...matchedTracks.map(({ track, release }) => `${release.name} — ${track.name}`),
180
- ];
181
-
182
- after(async () => {
183
- try {
184
- const verifyToken = await signOrderToken({
185
- orderId: String(recordedOrder.id),
186
- email: customerEmail,
187
- secret: orderTokenSecret,
188
- expiresIn: orderTokenExpiry,
189
- });
190
- const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
191
- const { subject, html } = await renderConfirmation({
192
- branding,
193
- verifyUrl,
194
- itemNames,
195
- totalCents: session.amount_total ?? 0,
196
- });
197
- await email.send({
198
- from: emailFrom,
199
- to: customerEmail,
200
- subject,
201
- html,
202
- });
203
- } catch (err) {
204
- console.error(
205
- `[stripe-webhook] failed to send purchase confirmation to ${customerEmail} for session ${session.id}:`,
206
- err,
207
- );
208
- }
209
- });
58
+ after(() =>
59
+ sendPurchaseConfirmation(
60
+ {
61
+ orderId: result.order.id,
62
+ customerEmail: result.customerEmail,
63
+ itemNames: result.itemNames,
64
+ totalCents: result.totalCents,
65
+ },
66
+ deps,
67
+ ),
68
+ );
210
69
 
211
70
  return Response.json({ received: true });
212
71
  };
213
72
  }
214
-
215
- /**
216
- * Parse the JSON-stringified `{ "r<id>" | "t<id>": cents }` amount map from
217
- * Stripe metadata, returning `{}` on any failure. Non-finite values are
218
- * dropped so a malformed entry falls back to the catalog price at the call site.
219
- */
220
- function safeAmountMap(value: string | undefined | null): Record<string, number> {
221
- if (!value) return {};
222
- try {
223
- const parsed: unknown = JSON.parse(value);
224
- if (typeof parsed !== "object" || parsed === null) return {};
225
- const out: Record<string, number> = {};
226
- for (const [key, raw] of Object.entries(parsed as Record<string, unknown>)) {
227
- const n = Number(raw);
228
- if (Number.isFinite(n)) out[key] = n;
229
- }
230
- return out;
231
- } catch {
232
- return {};
233
- }
234
- }
235
-
236
- /** Parse a JSON-stringified id array from Stripe metadata, returning `[]` on any failure. NaN entries are dropped. */
237
- function safeJsonIdList(value: string | undefined | null): number[] {
238
- if (!value) return [];
239
- try {
240
- const parsed: unknown = JSON.parse(value);
241
- if (!Array.isArray(parsed)) return [];
242
- return parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n));
243
- } catch {
244
- return [];
245
- }
246
- }