@gigamusic/checkout 4.4.0 → 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 CHANGED
@@ -13,3 +13,41 @@ export const POST = createCheckoutHandler({
13
13
  baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
14
14
  });
15
15
  ```
16
+
17
+ ## Fulfilling without waiting for the webhook
18
+
19
+ Stripe redirects the customer to your success page and delivers the webhook in
20
+ parallel — nothing orders those two. A success page that only *reads* the order
21
+ will sometimes find nothing and leave a paying customer with no download links.
22
+
23
+ `fulfillCheckoutSession` is the same idempotent write the webhook uses, exported
24
+ so the success page can fulfill on the spot. Whichever path runs first writes the
25
+ row (the unique constraint on `stripeSessionId` settles the race); the other gets
26
+ `already-recorded` and skips the email, so exactly one confirmation goes out.
27
+
28
+ ```ts
29
+ import { fulfillCheckoutSession, sendPurchaseConfirmation } from "@gigamusic/checkout";
30
+ import { after } from "next/server";
31
+
32
+ const session = await stripe.checkout.sessions.retrieve(sessionId);
33
+ const result = await fulfillCheckoutSession(session, { queries, site });
34
+
35
+ if (result.status === "created") {
36
+ after(() =>
37
+ sendPurchaseConfirmation(
38
+ {
39
+ orderId: result.order.id,
40
+ customerEmail: result.customerEmail,
41
+ itemNames: result.itemNames,
42
+ totalCents: result.totalCents,
43
+ },
44
+ emailDeps,
45
+ ),
46
+ );
47
+ }
48
+ ```
49
+
50
+ `status: "skipped"` carries a `reason` — `"site-mismatch"` (a session from
51
+ another site sharing the Stripe account, or a forged `session_id`), `"no-items"`,
52
+ or `"unpaid"` (a delayed-notification payment that hasn't cleared; wait for the
53
+ webhook rather than handing over downloads).
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import Stripe from 'stripe';
3
- import { Queries } from '@gigamusic/db';
3
+ import { Queries, OrderWithItems } from '@gigamusic/db';
4
4
  import { StorageProvider } from '@gigamusic/storage';
5
5
  import { EmailProvider, EmailBranding, PurchaseConfirmationArgs } from '@gigamusic/email';
6
6
 
@@ -58,23 +58,19 @@ interface CheckoutDeps {
58
58
  productName: string;
59
59
  } | null | undefined>);
60
60
  }
61
- interface WebhookDeps {
62
- /**
63
- * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
64
- * at the consumer-app boundary) or a `stripeSecret` string the handler uses
65
- * to construct one. Pass exactly one — `stripe` wins if both are provided.
66
- */
67
- stripe?: Stripe;
68
- stripeSecret?: string;
69
- webhookSecret: string;
61
+ /** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
62
+ interface FulfillSessionDeps {
70
63
  queries: Queries;
71
64
  /**
72
- * The site (alias) this endpoint serves. Stripe broadcasts every event to
73
- * every registered endpoint, so the handler ignores any
74
- * `checkout.session.completed` whose `metadata.site` doesn't match — this is
65
+ * The site (alias) this fulfillment path serves. Stripe broadcasts every
66
+ * event to every registered endpoint (and a success URL can be hand-forged),
67
+ * so any session whose `metadata.site` doesn't match is skipped — this is
75
68
  * what lets one Stripe account safely back multiple sites.
76
69
  */
77
70
  site: string;
71
+ }
72
+ /** What `sendPurchaseConfirmation` needs to mail the customer their magic link. */
73
+ interface PurchaseConfirmationDeps {
78
74
  email: EmailProvider;
79
75
  branding: EmailBranding;
80
76
  emailFrom: string;
@@ -99,6 +95,16 @@ interface WebhookDeps {
99
95
  */
100
96
  renderConfirmation?: (args: PurchaseConfirmationArgs) => RenderedEmail | Promise<RenderedEmail>;
101
97
  }
98
+ interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
99
+ /**
100
+ * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
101
+ * at the consumer-app boundary) or a `stripeSecret` string the handler uses
102
+ * to construct one. Pass exactly one — `stripe` wins if both are provided.
103
+ */
104
+ stripe?: Stripe;
105
+ stripeSecret?: string;
106
+ webhookSecret: string;
107
+ }
102
108
  interface DownloadDeps {
103
109
  queries: Queries;
104
110
  storage: StorageProvider;
@@ -126,35 +132,92 @@ declare function createCheckoutHandler(deps: CheckoutDeps): (req: NextRequest) =
126
132
 
127
133
  /**
128
134
  * Build the POST handler for Stripe's webhook endpoint. Verifies the
129
- * signature, records the order on `checkout.session.completed`, then defers
130
- * the magic-link email via `after()` so Stripe sees a fast 200.
135
+ * signature, records the order on `checkout.session.completed` via the shared
136
+ * `fulfillCheckoutSession`, then defers the magic-link email via `after()` so
137
+ * Stripe sees a fast 200.
138
+ *
139
+ * The handler is not the only fulfillment path — a consumer's success page can
140
+ * call `fulfillCheckoutSession` directly rather than waiting on webhook
141
+ * delivery. Whichever runs first writes the row; the other gets
142
+ * `already-recorded` and skips the email, so the customer is mailed exactly
143
+ * once no matter who wins. Every non-`created` outcome answers Stripe with
144
+ * `{ received: true }` so it doesn't retry.
145
+ */
146
+ declare function createStripeWebhookHandler(deps: WebhookDeps): (req: NextRequest) => Promise<Response>;
147
+
148
+ type FulfillSessionResult =
149
+ /** This call wrote the order row. The caller owns sending the confirmation email. */
150
+ {
151
+ status: "created";
152
+ order: OrderWithItems;
153
+ itemNames: string[];
154
+ customerEmail: string;
155
+ totalCents: number;
156
+ }
157
+ /**
158
+ * The order was already on disk — either the pre-flight lookup found it or a
159
+ * concurrent writer won the unique-constraint race. `order` is null only if
160
+ * the re-read after a lost race somehow came back empty.
161
+ */
162
+ | {
163
+ status: "already-recorded";
164
+ order: OrderWithItems | null;
165
+ }
166
+ /** Nothing was written and nothing is owed. */
167
+ | {
168
+ status: "skipped";
169
+ reason: "site-mismatch" | "no-items" | "unpaid";
170
+ };
171
+ /**
172
+ * Turn a completed Stripe Checkout Session into a persisted order, idempotently.
173
+ *
174
+ * This is the single fulfillment path shared by the Stripe webhook and any
175
+ * consumer that wants to fulfill synchronously — e.g. a post-checkout success
176
+ * page that would otherwise have to wait on webhook delivery to show download
177
+ * links. Whoever calls first writes the row; everyone after gets
178
+ * `already-recorded`. That guarantee rests on the unique constraint over
179
+ * `stripeSessionId` inside `recordCompletedOrder`, not on call ordering, so
180
+ * concurrent callers are safe.
131
181
  *
132
- * Idempotency is enforced two ways: a pre-flight `getOrderByStripeSessionId`
133
- * lookup, plus a unique-constraint catch around `recordCompletedOrder` for
134
- * the case where two webhook deliveries race past the lookup. Both paths
135
- * short-circuit with `{ received: true }` so Stripe doesn't retry.
182
+ * Only `status: "created"` should trigger side effects (the confirmation
183
+ * email) that status is returned to exactly one caller per session.
136
184
  *
137
185
  * ### Published-only resolution (gotcha)
138
186
  *
139
- * The handler resolves the release/track IDs from `session.metadata` against
187
+ * Release/track IDs from `session.metadata` are resolved against
140
188
  * `queries.listPublishedReleases()`. Anything not in the published catalog at
141
- * the moment the webhook fires is silently dropped from the persisted order's
142
- * line items — the customer is still charged, but the order row doesn't
143
- * include the unpublished item.
189
+ * fulfillment time is silently dropped from the persisted order's line items —
190
+ * the customer is still charged, but the order row doesn't include the
191
+ * unpublished item.
144
192
  *
145
193
  * In the canonical flow this can't happen: `createCheckoutHandler` filters on
146
194
  * the same `listPublishedReleases()` before it ever creates the Stripe
147
- * Session. The only ways an unpublished ID can reach this handler are:
148
- * - an admin unpublishes between session-create and webhook-delivery
195
+ * Session. The only ways an unpublished ID can reach here are:
196
+ * - an admin unpublishes between session-create and fulfillment
149
197
  * (a several-second race window),
150
198
  * - a consumer bypasses `createCheckoutHandler` and creates Stripe sessions
151
199
  * directly with their own metadata.
152
200
  *
153
- * If either of those is a realistic concern, swap the resolution call here
154
- * to `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be
155
- * aware that doing so will surface unpublished items in admin order views.
201
+ * If either is a realistic concern, swap the resolution call here to
202
+ * `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be aware
203
+ * that doing so will surface unpublished items in admin order views.
156
204
  */
157
- declare function createStripeWebhookHandler(deps: WebhookDeps): (req: NextRequest) => Promise<Response>;
205
+ declare function fulfillCheckoutSession(session: Stripe.Checkout.Session, deps: FulfillSessionDeps): Promise<FulfillSessionResult>;
206
+ /**
207
+ * Render and send the post-purchase confirmation email carrying the durable
208
+ * magic link back to the customer's downloads.
209
+ *
210
+ * Errors are logged, never thrown: the order is already recorded and paid for,
211
+ * so a mail-provider hiccup must not fail the caller (a webhook returning 500
212
+ * would make Stripe retry a fulfillment that already succeeded). Callers
213
+ * typically wrap this in `after()` so the response isn't blocked on it.
214
+ */
215
+ declare function sendPurchaseConfirmation(args: {
216
+ orderId: number;
217
+ customerEmail: string;
218
+ itemNames: string[];
219
+ totalCents: number;
220
+ }, deps: PurchaseConfirmationDeps): Promise<void>;
158
221
 
159
222
  interface RouteContext$2 {
160
223
  params: Promise<{
@@ -237,4 +300,4 @@ declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextR
237
300
  */
238
301
  declare function createSwZipFallbackHandler(): () => Response;
239
302
 
240
- export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler };
303
+ export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
package/dist/index.js CHANGED
@@ -170,13 +170,74 @@ function jsonError(message, status, cause) {
170
170
  }
171
171
  return Response.json({ error: message }, { status });
172
172
  }
173
- function createStripeWebhookHandler(deps) {
173
+ async function fulfillCheckoutSession(session, deps) {
174
+ const { queries, site } = deps;
175
+ if (session.metadata?.site !== site) {
176
+ return { status: "skipped", reason: "site-mismatch" };
177
+ }
178
+ if (session.payment_status === "unpaid") {
179
+ return { status: "skipped", reason: "unpaid" };
180
+ }
181
+ const existing = await queries.getOrderByStripeSessionId(session.id);
182
+ if (existing) {
183
+ return { status: "already-recorded", order: existing };
184
+ }
185
+ const releaseIds = safeJsonIdList(session.metadata?.release_ids);
186
+ const trackIds = safeJsonIdList(session.metadata?.track_ids);
187
+ const chargedAmounts = safeAmountMap(session.metadata?.amounts);
188
+ if (releaseIds.length === 0 && trackIds.length === 0) {
189
+ return { status: "skipped", reason: "no-items" };
190
+ }
191
+ const allReleases = await queries.listPublishedReleases();
192
+ const releaseById = new Map(allReleases.map((r) => [r.id, r]));
193
+ const trackContext = /* @__PURE__ */ new Map();
194
+ for (const release of allReleases) {
195
+ for (const track of release.tracks) {
196
+ trackContext.set(track.id, { track, release });
197
+ }
198
+ }
199
+ const matchedReleases = releaseIds.map((id) => releaseById.get(id)).filter((r) => r !== void 0);
200
+ const matchedTracks = trackIds.map((id) => trackContext.get(id)).filter(
201
+ (c) => c !== void 0
202
+ );
203
+ const orderItems = [
204
+ ...matchedReleases.map((r) => ({
205
+ releaseId: r.id,
206
+ price: chargedAmounts[`r${r.id}`] ?? r.price
207
+ })),
208
+ ...matchedTracks.map(({ track }) => ({
209
+ trackId: track.id,
210
+ price: chargedAmounts[`t${track.id}`] ?? track.price
211
+ }))
212
+ ];
213
+ const customerEmail = session.customer_details?.email ?? "";
214
+ const totalCents = session.amount_total ?? 0;
215
+ let order;
216
+ try {
217
+ order = await queries.recordCompletedOrder({
218
+ stripeSessionId: session.id,
219
+ stripePaymentId: typeof session.payment_intent === "string" ? session.payment_intent : null,
220
+ email: customerEmail,
221
+ amountTotal: totalCents,
222
+ items: orderItems
223
+ });
224
+ } catch (err) {
225
+ if (isUniqueConstraintError(err)) {
226
+ return {
227
+ status: "already-recorded",
228
+ order: await queries.getOrderByStripeSessionId(session.id) ?? null
229
+ };
230
+ }
231
+ throw err;
232
+ }
233
+ const itemNames = [
234
+ ...matchedReleases.map((r) => r.name),
235
+ ...matchedTracks.map(({ track, release }) => `${release.name} \u2014 ${track.name}`)
236
+ ];
237
+ return { status: "created", order, itemNames, customerEmail, totalCents };
238
+ }
239
+ async function sendPurchaseConfirmation(args, deps) {
174
240
  const {
175
- stripe: stripeOverride,
176
- stripeSecret,
177
- webhookSecret,
178
- queries,
179
- site,
180
241
  email,
181
242
  branding,
182
243
  emailFrom,
@@ -186,119 +247,33 @@ function createStripeWebhookHandler(deps) {
186
247
  verifyPath = "/orders/verify",
187
248
  renderConfirmation = renderPurchaseConfirmation
188
249
  } = deps;
189
- if (!stripeOverride && !stripeSecret) {
190
- throw new Error(
191
- "createStripeWebhookHandler: must supply either `stripe` or `stripeSecret`"
250
+ if (!args.customerEmail) {
251
+ console.warn(
252
+ `[gigamusic/checkout] order ${args.orderId} has no customer email \u2014 recorded, but the customer cannot retrieve downloads.`
192
253
  );
254
+ return;
193
255
  }
194
- const stripe = stripeOverride ?? new Stripe(stripeSecret, { typescript: true });
195
- return async (req) => {
196
- const body = await req.text();
197
- const sig = req.headers.get("stripe-signature");
198
- if (!sig) {
199
- return Response.json({ error: "Missing signature" }, { status: 400 });
200
- }
201
- let event;
202
- try {
203
- event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
204
- } catch {
205
- return Response.json({ error: "Invalid signature" }, { status: 400 });
206
- }
207
- if (event.type !== "checkout.session.completed") {
208
- return Response.json({ received: true });
209
- }
210
- const session = event.data.object;
211
- if (session.metadata?.site !== site) {
212
- return Response.json({ received: true });
213
- }
214
- const existing = await queries.getOrderByStripeSessionId(session.id);
215
- if (existing) {
216
- return Response.json({ received: true });
217
- }
218
- const releaseIds = safeJsonIdList(session.metadata?.release_ids);
219
- const trackIds = safeJsonIdList(session.metadata?.track_ids);
220
- const chargedAmounts = safeAmountMap(session.metadata?.amounts);
221
- if (releaseIds.length === 0 && trackIds.length === 0) {
222
- return Response.json({ received: true });
223
- }
224
- const allReleases = await queries.listPublishedReleases();
225
- const releaseById = new Map(allReleases.map((r) => [r.id, r]));
226
- const trackContext = /* @__PURE__ */ new Map();
227
- for (const release of allReleases) {
228
- for (const track of release.tracks) {
229
- trackContext.set(track.id, { track, release });
230
- }
231
- }
232
- const matchedReleases = releaseIds.map((id) => releaseById.get(id)).filter((r) => r !== void 0);
233
- const matchedTracks = trackIds.map((id) => trackContext.get(id)).filter(
234
- (c) => c !== void 0
235
- );
236
- const orderItems = [
237
- ...matchedReleases.map((r) => ({
238
- releaseId: r.id,
239
- price: chargedAmounts[`r${r.id}`] ?? r.price
240
- })),
241
- ...matchedTracks.map(({ track }) => ({
242
- trackId: track.id,
243
- price: chargedAmounts[`t${track.id}`] ?? track.price
244
- }))
245
- ];
246
- const customerEmail = session.customer_details?.email ?? "";
247
- let recordedOrder;
248
- try {
249
- recordedOrder = await queries.recordCompletedOrder({
250
- stripeSessionId: session.id,
251
- stripePaymentId: typeof session.payment_intent === "string" ? session.payment_intent : null,
252
- email: customerEmail,
253
- amountTotal: session.amount_total ?? 0,
254
- items: orderItems
255
- });
256
- } catch (err) {
257
- if (isUniqueConstraintError(err)) {
258
- return Response.json({ received: true });
259
- }
260
- throw err;
261
- }
262
- if (!customerEmail) {
263
- console.warn(
264
- `[stripe-webhook] checkout.session.completed (session=${session.id}) has no customer_details.email \u2014 order recorded but customer cannot retrieve downloads.`
265
- );
266
- return Response.json({ received: true });
267
- }
268
- const itemNames = [
269
- ...matchedReleases.map((r) => r.name),
270
- ...matchedTracks.map(({ track, release }) => `${release.name} \u2014 ${track.name}`)
271
- ];
272
- after(async () => {
273
- try {
274
- const verifyToken = await signOrderToken({
275
- orderId: String(recordedOrder.id),
276
- email: customerEmail,
277
- secret: orderTokenSecret,
278
- expiresIn: orderTokenExpiry
279
- });
280
- const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
281
- const { subject, html } = await renderConfirmation({
282
- branding,
283
- verifyUrl,
284
- itemNames,
285
- totalCents: session.amount_total ?? 0
286
- });
287
- await email.send({
288
- from: emailFrom,
289
- to: customerEmail,
290
- subject,
291
- html
292
- });
293
- } catch (err) {
294
- console.error(
295
- `[stripe-webhook] failed to send purchase confirmation to ${customerEmail} for session ${session.id}:`,
296
- err
297
- );
298
- }
256
+ try {
257
+ const verifyToken = await signOrderToken({
258
+ orderId: String(args.orderId),
259
+ email: args.customerEmail,
260
+ secret: orderTokenSecret,
261
+ expiresIn: orderTokenExpiry
299
262
  });
300
- return Response.json({ received: true });
301
- };
263
+ const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
264
+ const { subject, html } = await renderConfirmation({
265
+ branding,
266
+ verifyUrl,
267
+ itemNames: args.itemNames,
268
+ totalCents: args.totalCents
269
+ });
270
+ await email.send({ from: emailFrom, to: args.customerEmail, subject, html });
271
+ } catch (err) {
272
+ console.error(
273
+ `[gigamusic/checkout] failed to send purchase confirmation to ${args.customerEmail} for order ${args.orderId}:`,
274
+ err
275
+ );
276
+ }
302
277
  }
303
278
  function safeAmountMap(value) {
304
279
  if (!value) return {};
@@ -326,6 +301,50 @@ function safeJsonIdList(value) {
326
301
  }
327
302
  }
328
303
 
304
+ // src/webhook.ts
305
+ function createStripeWebhookHandler(deps) {
306
+ const { stripe: stripeOverride, stripeSecret, webhookSecret, queries, site } = deps;
307
+ if (!stripeOverride && !stripeSecret) {
308
+ throw new Error(
309
+ "createStripeWebhookHandler: must supply either `stripe` or `stripeSecret`"
310
+ );
311
+ }
312
+ const stripe = stripeOverride ?? new Stripe(stripeSecret, { typescript: true });
313
+ return async (req) => {
314
+ const body = await req.text();
315
+ const sig = req.headers.get("stripe-signature");
316
+ if (!sig) {
317
+ return Response.json({ error: "Missing signature" }, { status: 400 });
318
+ }
319
+ let event;
320
+ try {
321
+ event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
322
+ } catch {
323
+ return Response.json({ error: "Invalid signature" }, { status: 400 });
324
+ }
325
+ if (event.type !== "checkout.session.completed") {
326
+ return Response.json({ received: true });
327
+ }
328
+ const session = event.data.object;
329
+ const result = await fulfillCheckoutSession(session, { queries, site });
330
+ if (result.status !== "created") {
331
+ return Response.json({ received: true });
332
+ }
333
+ after(
334
+ () => sendPurchaseConfirmation(
335
+ {
336
+ orderId: result.order.id,
337
+ customerEmail: result.customerEmail,
338
+ itemNames: result.itemNames,
339
+ totalCents: result.totalCents
340
+ },
341
+ deps
342
+ )
343
+ );
344
+ return Response.json({ received: true });
345
+ };
346
+ }
347
+
329
348
  // src/download.ts
330
349
  function createDownloadHandler(deps) {
331
350
  const { queries, storage } = deps;
@@ -757,6 +776,6 @@ function createSwZipFallbackHandler() {
757
776
  };
758
777
  }
759
778
 
760
- export { createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler };
779
+ export { createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
761
780
  //# sourceMappingURL=index.js.map
762
781
  //# sourceMappingURL=index.js.map