@gigamusic/checkout 4.4.0 → 4.6.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/dist/index.js CHANGED
@@ -15,6 +15,24 @@ function resolveUnitAmount(overrideCents, catalogCents) {
15
15
  }
16
16
  return catalogCents;
17
17
  }
18
+ function apportion(totalCents, members) {
19
+ const result = /* @__PURE__ */ new Map();
20
+ if (members.length === 0) return result;
21
+ const total = Math.max(0, Math.round(totalCents));
22
+ const sum = members.reduce((s, m) => s + Math.max(0, m.price), 0);
23
+ const weights = sum > 0 ? members.map((m) => Math.max(0, m.price) / sum) : members.map(() => 1 / members.length);
24
+ const exact = weights.map((w) => total * w);
25
+ const base = exact.map((v) => Math.floor(v));
26
+ let remainder = total - base.reduce((s, v) => s + v, 0);
27
+ const order = members.map((m, i) => ({ i, frac: exact[i] - base[i], price: m.price, id: m.id })).sort((a, b) => b.frac - a.frac || b.price - a.price || a.id - b.id);
28
+ for (const { i } of order) {
29
+ if (remainder <= 0) break;
30
+ base[i] += 1;
31
+ remainder -= 1;
32
+ }
33
+ members.forEach((m, i) => result.set(m.id, base[i]));
34
+ return result;
35
+ }
18
36
  function createCheckoutHandler(deps) {
19
37
  const {
20
38
  stripeSecret,
@@ -24,7 +42,8 @@ function createCheckoutHandler(deps) {
24
42
  successPath = "/checkout/success",
25
43
  cancelPath = "/cart",
26
44
  currency = "usd",
27
- catalogPurchase
45
+ catalogPurchase,
46
+ bundlePurchase
28
47
  } = deps;
29
48
  const stripe = new Stripe(stripeSecret, { typescript: true });
30
49
  return async (req) => {
@@ -41,6 +60,14 @@ function createCheckoutHandler(deps) {
41
60
  const successUrl = `${baseUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}`;
42
61
  const cancelUrl = `${baseUrl}${cancelPath}`;
43
62
  const isCatalogPurchase = items.some((i) => i.kind === "catalog");
63
+ const bundleIds = [
64
+ ...new Set(
65
+ items.filter((i) => i.kind === "bundle" && typeof i.bundleId === "string").map((i) => i.bundleId)
66
+ )
67
+ ];
68
+ if (isCatalogPurchase && bundleIds.length > 0) {
69
+ return jsonError("Cannot mix a catalog purchase with a bundle", 400);
70
+ }
44
71
  try {
45
72
  if (isCatalogPurchase) {
46
73
  const resolved = typeof catalogPurchase === "function" ? await catalogPurchase() : catalogPurchase;
@@ -73,9 +100,12 @@ function createCheckoutHandler(deps) {
73
100
  }
74
101
  const releaseIds = items.filter((i) => i.kind === "release" && i.id != null).map((i) => i.id);
75
102
  const trackIds = items.filter((i) => i.kind === "track" && i.id != null).map((i) => i.id);
76
- if (releaseIds.length === 0 && trackIds.length === 0) {
103
+ if (releaseIds.length === 0 && trackIds.length === 0 && bundleIds.length === 0) {
77
104
  return jsonError("No valid items found", 400);
78
105
  }
106
+ if (bundleIds.length > 0 && typeof bundlePurchase !== "function") {
107
+ return jsonError("Bundle purchases are not configured", 400);
108
+ }
79
109
  const releaseAmountOverride = new Map(
80
110
  items.filter(
81
111
  (i) => i.kind === "release" && i.id != null && typeof i.priceCents === "number"
@@ -97,7 +127,7 @@ function createCheckoutHandler(deps) {
97
127
  }
98
128
  const releaseLineItems = releaseIds.map((id) => releaseById.get(id)).filter((r) => r !== void 0).map((r) => {
99
129
  const amount = resolveUnitAmount(releaseAmountOverride.get(r.id), r.price);
100
- chargedAmounts[`r${r.id}`] = amount;
130
+ chargedAmounts[`r${r.id}`] = (chargedAmounts[`r${r.id}`] ?? 0) + amount;
101
131
  return {
102
132
  price_data: {
103
133
  currency,
@@ -117,7 +147,7 @@ function createCheckoutHandler(deps) {
117
147
  trackAmountOverride.get(track.id),
118
148
  track.price
119
149
  );
120
- chargedAmounts[`t${track.id}`] = amount;
150
+ chargedAmounts[`t${track.id}`] = (chargedAmounts[`t${track.id}`] ?? 0) + amount;
121
151
  return {
122
152
  price_data: {
123
153
  currency,
@@ -130,12 +160,57 @@ function createCheckoutHandler(deps) {
130
160
  quantity: 1
131
161
  };
132
162
  });
133
- const lineItems = [...releaseLineItems, ...trackLineItems];
163
+ const bundleLineItems = [];
164
+ const bundleMemberIds = [];
165
+ const unavailableBundleIds = [];
166
+ for (const bundleId of bundleIds) {
167
+ const resolved = await bundlePurchase(bundleId);
168
+ const members = (resolved?.releaseIds ?? []).map((id) => releaseById.get(id)).filter((r) => r !== void 0);
169
+ if (!resolved || members.length === 0) {
170
+ unavailableBundleIds.push(bundleId);
171
+ continue;
172
+ }
173
+ const total = Math.max(
174
+ STRIPE_MIN_CHARGE_CENTS,
175
+ Math.round(resolved.totalCents)
176
+ );
177
+ const cover = members.find((m) => m.coverImageUrl)?.coverImageUrl;
178
+ bundleLineItems.push({
179
+ price_data: {
180
+ currency,
181
+ product_data: {
182
+ name: resolved.productName,
183
+ ...cover ? { images: [toAbsoluteUrl(cover, baseUrl)] } : {}
184
+ },
185
+ unit_amount: total
186
+ },
187
+ quantity: 1
188
+ });
189
+ for (const [id, cents] of apportion(
190
+ total,
191
+ members.map((m) => ({ id: m.id, price: m.price }))
192
+ )) {
193
+ chargedAmounts[`r${id}`] = (chargedAmounts[`r${id}`] ?? 0) + cents;
194
+ bundleMemberIds.push(id);
195
+ }
196
+ }
197
+ if (unavailableBundleIds.length > 0) {
198
+ return Response.json(
199
+ { error: "bundle-unavailable", bundleIds: unavailableBundleIds },
200
+ { status: 409 }
201
+ );
202
+ }
203
+ const lineItems = [...bundleLineItems, ...releaseLineItems, ...trackLineItems];
134
204
  if (lineItems.length === 0) {
135
205
  return jsonError("No valid items found", 400);
136
206
  }
137
- const resolvedReleaseIds = releaseLineItems.length ? releaseIds.filter((id) => releaseById.has(id)) : [];
138
- const resolvedTrackIds = trackLineItems.length ? trackIds.filter((id) => trackContext.has(id)) : [];
207
+ const resolvedReleaseIds = [
208
+ .../* @__PURE__ */ new Set([
209
+ ...releaseLineItems.length ? releaseIds.filter((id) => releaseById.has(id)) : [],
210
+ ...bundleMemberIds
211
+ ])
212
+ ];
213
+ const resolvedTrackIds = trackLineItems.length ? [...new Set(trackIds.filter((id) => trackContext.has(id)))] : [];
139
214
  const session = await stripe.checkout.sessions.create({
140
215
  mode: "payment",
141
216
  line_items: lineItems,
@@ -143,7 +218,10 @@ function createCheckoutHandler(deps) {
143
218
  site,
144
219
  release_ids: JSON.stringify(resolvedReleaseIds),
145
220
  track_ids: JSON.stringify(resolvedTrackIds),
146
- amounts: JSON.stringify(chargedAmounts)
221
+ amounts: JSON.stringify(chargedAmounts),
222
+ // Ignored by fulfillment — bundles decompose into per-release order
223
+ // items — but invaluable when supporting "what did I actually buy?".
224
+ ...bundleIds.length > 0 ? { bundle_ids: JSON.stringify(bundleIds) } : {}
147
225
  },
148
226
  success_url: successUrl,
149
227
  cancel_url: cancelUrl
@@ -170,13 +248,74 @@ function jsonError(message, status, cause) {
170
248
  }
171
249
  return Response.json({ error: message }, { status });
172
250
  }
173
- function createStripeWebhookHandler(deps) {
251
+ async function fulfillCheckoutSession(session, deps) {
252
+ const { queries, site } = deps;
253
+ if (session.metadata?.site !== site) {
254
+ return { status: "skipped", reason: "site-mismatch" };
255
+ }
256
+ if (session.payment_status === "unpaid") {
257
+ return { status: "skipped", reason: "unpaid" };
258
+ }
259
+ const existing = await queries.getOrderByStripeSessionId(session.id);
260
+ if (existing) {
261
+ return { status: "already-recorded", order: existing };
262
+ }
263
+ const releaseIds = safeJsonIdList(session.metadata?.release_ids);
264
+ const trackIds = safeJsonIdList(session.metadata?.track_ids);
265
+ const chargedAmounts = safeAmountMap(session.metadata?.amounts);
266
+ if (releaseIds.length === 0 && trackIds.length === 0) {
267
+ return { status: "skipped", reason: "no-items" };
268
+ }
269
+ const allReleases = await queries.listPublishedReleases();
270
+ const releaseById = new Map(allReleases.map((r) => [r.id, r]));
271
+ const trackContext = /* @__PURE__ */ new Map();
272
+ for (const release of allReleases) {
273
+ for (const track of release.tracks) {
274
+ trackContext.set(track.id, { track, release });
275
+ }
276
+ }
277
+ const matchedReleases = releaseIds.map((id) => releaseById.get(id)).filter((r) => r !== void 0);
278
+ const matchedTracks = trackIds.map((id) => trackContext.get(id)).filter(
279
+ (c) => c !== void 0
280
+ );
281
+ const orderItems = [
282
+ ...matchedReleases.map((r) => ({
283
+ releaseId: r.id,
284
+ price: chargedAmounts[`r${r.id}`] ?? r.price
285
+ })),
286
+ ...matchedTracks.map(({ track }) => ({
287
+ trackId: track.id,
288
+ price: chargedAmounts[`t${track.id}`] ?? track.price
289
+ }))
290
+ ];
291
+ const customerEmail = session.customer_details?.email ?? "";
292
+ const totalCents = session.amount_total ?? 0;
293
+ let order;
294
+ try {
295
+ order = await queries.recordCompletedOrder({
296
+ stripeSessionId: session.id,
297
+ stripePaymentId: typeof session.payment_intent === "string" ? session.payment_intent : null,
298
+ email: customerEmail,
299
+ amountTotal: totalCents,
300
+ items: orderItems
301
+ });
302
+ } catch (err) {
303
+ if (isUniqueConstraintError(err)) {
304
+ return {
305
+ status: "already-recorded",
306
+ order: await queries.getOrderByStripeSessionId(session.id) ?? null
307
+ };
308
+ }
309
+ throw err;
310
+ }
311
+ const itemNames = [
312
+ ...matchedReleases.map((r) => r.name),
313
+ ...matchedTracks.map(({ track, release }) => `${release.name} \u2014 ${track.name}`)
314
+ ];
315
+ return { status: "created", order, itemNames, customerEmail, totalCents };
316
+ }
317
+ async function sendPurchaseConfirmation(args, deps) {
174
318
  const {
175
- stripe: stripeOverride,
176
- stripeSecret,
177
- webhookSecret,
178
- queries,
179
- site,
180
319
  email,
181
320
  branding,
182
321
  emailFrom,
@@ -186,119 +325,33 @@ function createStripeWebhookHandler(deps) {
186
325
  verifyPath = "/orders/verify",
187
326
  renderConfirmation = renderPurchaseConfirmation
188
327
  } = deps;
189
- if (!stripeOverride && !stripeSecret) {
190
- throw new Error(
191
- "createStripeWebhookHandler: must supply either `stripe` or `stripeSecret`"
328
+ if (!args.customerEmail) {
329
+ console.warn(
330
+ `[gigamusic/checkout] order ${args.orderId} has no customer email \u2014 recorded, but the customer cannot retrieve downloads.`
192
331
  );
332
+ return;
193
333
  }
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
- }
334
+ try {
335
+ const verifyToken = await signOrderToken({
336
+ orderId: String(args.orderId),
337
+ email: args.customerEmail,
338
+ secret: orderTokenSecret,
339
+ expiresIn: orderTokenExpiry
299
340
  });
300
- return Response.json({ received: true });
301
- };
341
+ const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
342
+ const { subject, html } = await renderConfirmation({
343
+ branding,
344
+ verifyUrl,
345
+ itemNames: args.itemNames,
346
+ totalCents: args.totalCents
347
+ });
348
+ await email.send({ from: emailFrom, to: args.customerEmail, subject, html });
349
+ } catch (err) {
350
+ console.error(
351
+ `[gigamusic/checkout] failed to send purchase confirmation to ${args.customerEmail} for order ${args.orderId}:`,
352
+ err
353
+ );
354
+ }
302
355
  }
303
356
  function safeAmountMap(value) {
304
357
  if (!value) return {};
@@ -326,6 +379,50 @@ function safeJsonIdList(value) {
326
379
  }
327
380
  }
328
381
 
382
+ // src/webhook.ts
383
+ function createStripeWebhookHandler(deps) {
384
+ const { stripe: stripeOverride, stripeSecret, webhookSecret, queries, site } = deps;
385
+ if (!stripeOverride && !stripeSecret) {
386
+ throw new Error(
387
+ "createStripeWebhookHandler: must supply either `stripe` or `stripeSecret`"
388
+ );
389
+ }
390
+ const stripe = stripeOverride ?? new Stripe(stripeSecret, { typescript: true });
391
+ return async (req) => {
392
+ const body = await req.text();
393
+ const sig = req.headers.get("stripe-signature");
394
+ if (!sig) {
395
+ return Response.json({ error: "Missing signature" }, { status: 400 });
396
+ }
397
+ let event;
398
+ try {
399
+ event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
400
+ } catch {
401
+ return Response.json({ error: "Invalid signature" }, { status: 400 });
402
+ }
403
+ if (event.type !== "checkout.session.completed") {
404
+ return Response.json({ received: true });
405
+ }
406
+ const session = event.data.object;
407
+ const result = await fulfillCheckoutSession(session, { queries, site });
408
+ if (result.status !== "created") {
409
+ return Response.json({ received: true });
410
+ }
411
+ after(
412
+ () => sendPurchaseConfirmation(
413
+ {
414
+ orderId: result.order.id,
415
+ customerEmail: result.customerEmail,
416
+ itemNames: result.itemNames,
417
+ totalCents: result.totalCents
418
+ },
419
+ deps
420
+ )
421
+ );
422
+ return Response.json({ received: true });
423
+ };
424
+ }
425
+
329
426
  // src/download.ts
330
427
  function createDownloadHandler(deps) {
331
428
  const { queries, storage } = deps;
@@ -757,6 +854,6 @@ function createSwZipFallbackHandler() {
757
854
  };
758
855
  }
759
856
 
760
- export { createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler };
857
+ export { createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
761
858
  //# sourceMappingURL=index.js.map
762
859
  //# sourceMappingURL=index.js.map