@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/README.md +38 -0
- package/dist/index.d.ts +93 -30
- package/dist/index.js +136 -117
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/fulfill.ts +266 -0
- package/src/index.ts +5 -0
- package/src/types.ts +20 -12
- package/src/webhook.ts +24 -198
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
|
-
|
|
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
|
|
75
|
-
* every registered endpoint
|
|
76
|
-
*
|
|
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 {
|
|
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
|
|
11
|
-
* the magic-link email via `after()` so
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
}
|