@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/README.md CHANGED
@@ -13,3 +13,93 @@ export const POST = createCheckoutHandler({
13
13
  baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
14
14
  });
15
15
  ```
16
+
17
+ ## Bundles — discounted subsets of the catalog
18
+
19
+ `catalogPurchase` is all-or-nothing. For a curated pack ("the remix EPs", "2024
20
+ releases"), send `{ kind: "bundle", bundleId }` and supply a `bundlePurchase`
21
+ resolver. It's called once per distinct `bundleId` in the cart.
22
+
23
+ ```ts
24
+ export const POST = createCheckoutHandler({
25
+ stripeSecret: process.env.STRIPE_SECRET_KEY!,
26
+ queries,
27
+ site,
28
+ baseUrl,
29
+ bundlePurchase: async (bundleId) => {
30
+ const bundle = await getBundle(bundleId); // your storage, your pricing
31
+ if (!bundle) return null; // -> 409 bundle-unavailable
32
+ return {
33
+ totalCents: bundle.discountedPrice,
34
+ productName: `${bundle.name} (${bundle.releaseIds.length} releases)`,
35
+ releaseIds: bundle.releaseIds,
36
+ };
37
+ },
38
+ });
39
+ ```
40
+
41
+ As with the catalog, the consumer owns pricing math. The handler:
42
+
43
+ - clamps `totalCents` up to the Stripe card minimum;
44
+ - resolves `releaseIds` against `listPublishedReleases()`, dropping any that
45
+ aren't published;
46
+ - **apportions** the charged total across the surviving members, proportional
47
+ to list price with largest-remainder rounding, so the `amounts` metadata sums
48
+ to exactly what Stripe charged — and therefore so do the `order_items` rows
49
+ `fulfillCheckoutSession` writes;
50
+ - stamps `bundle_ids` onto the session for support. Fulfillment ignores it:
51
+ bundles decompose into per-release order items, so downloads and order
52
+ history need no special handling.
53
+
54
+ Bundles compose with loose `release` / `track` items in a single session. A
55
+ release charged by both a bundle and a loose line yields one deduped order item
56
+ whose price is the sum.
57
+
58
+ Two rejections worth handling in the cart UI:
59
+
60
+ | Case | Response |
61
+ | --- | --- |
62
+ | Resolver returns `null`, or every member is unpublished | `409 { error: "bundle-unavailable", bundleIds }` |
63
+ | Cart mixes `kind: "catalog"` with `kind: "bundle"` | `400` |
64
+
65
+ The 409 is the expected path when an admin deletes or unpublishes a bundle still
66
+ sitting in a customer's saved cart — surface it as "this bundle is no longer
67
+ available" with a one-click remove, rather than a generic failure.
68
+
69
+ ## Fulfilling without waiting for the webhook
70
+
71
+ Stripe redirects the customer to your success page and delivers the webhook in
72
+ parallel — nothing orders those two. A success page that only *reads* the order
73
+ will sometimes find nothing and leave a paying customer with no download links.
74
+
75
+ `fulfillCheckoutSession` is the same idempotent write the webhook uses, exported
76
+ so the success page can fulfill on the spot. Whichever path runs first writes the
77
+ row (the unique constraint on `stripeSessionId` settles the race); the other gets
78
+ `already-recorded` and skips the email, so exactly one confirmation goes out.
79
+
80
+ ```ts
81
+ import { fulfillCheckoutSession, sendPurchaseConfirmation } from "@gigamusic/checkout";
82
+ import { after } from "next/server";
83
+
84
+ const session = await stripe.checkout.sessions.retrieve(sessionId);
85
+ const result = await fulfillCheckoutSession(session, { queries, site });
86
+
87
+ if (result.status === "created") {
88
+ after(() =>
89
+ sendPurchaseConfirmation(
90
+ {
91
+ orderId: result.order.id,
92
+ customerEmail: result.customerEmail,
93
+ itemNames: result.itemNames,
94
+ totalCents: result.totalCents,
95
+ },
96
+ emailDeps,
97
+ ),
98
+ );
99
+ }
100
+ ```
101
+
102
+ `status: "skipped"` carries a `reason` — `"site-mismatch"` (a session from
103
+ another site sharing the Stripe account, or a forged `session_id`), `"no-items"`,
104
+ or `"unpaid"` (a delayed-notification payment that hasn't cleared; wait for the
105
+ 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
 
@@ -9,10 +9,20 @@ interface RenderedEmail {
9
9
  subject: string;
10
10
  html: string;
11
11
  }
12
- /** Cart item shape accepted by `createCheckoutHandler`. `id` is required for "release" / "track"; ignored for "catalog". */
12
+ /**
13
+ * Cart item shape accepted by `createCheckoutHandler`. `id` is required for
14
+ * "release" / "track" and ignored for "catalog"; "bundle" carries `bundleId`
15
+ * instead, since bundle identifiers belong to the consumer and needn't be
16
+ * numeric.
17
+ */
13
18
  interface CheckoutCartItem {
14
- kind: "release" | "track" | "catalog";
19
+ kind: "release" | "track" | "catalog" | "bundle";
15
20
  id?: number;
21
+ /**
22
+ * Required for `kind: "bundle"`. Opaque to the package — handed straight to
23
+ * `bundlePurchase` for the consumer to resolve.
24
+ */
25
+ bundleId?: string;
16
26
  /**
17
27
  * Pay-what-you-want override, in cents. When present and ≥ the Stripe card
18
28
  * minimum, the customer is charged this instead of the catalog price — the
@@ -57,24 +67,49 @@ interface CheckoutDeps {
57
67
  totalCents: number;
58
68
  productName: string;
59
69
  } | null | undefined>);
70
+ /**
71
+ * Resolves a `kind: "bundle"` cart item — a discounted subset of the
72
+ * catalog, as opposed to `catalogPurchase`'s all-or-nothing line.
73
+ *
74
+ * Called once per distinct `bundleId` in the cart. As with the catalog, the
75
+ * consumer owns pricing math: hand back the final `totalCents` and the
76
+ * member `releaseIds`, and the handler apportions the total across those
77
+ * members (proportional to list price, largest-remainder) so the recorded
78
+ * order items sum to exactly what Stripe charged.
79
+ *
80
+ * Resolving to `null`/`undefined` means "no such bundle" — the handler
81
+ * replies 409 `{ error: "bundle-unavailable", bundleIds }` so the cart can
82
+ * offer to drop it. That's the expected path when an admin deletes or
83
+ * unpublishes a bundle still sitting in someone's saved cart.
84
+ */
85
+ bundlePurchase?: (bundleId: string) => Promise<ResolvedBundle | null | undefined> | ResolvedBundle | null | undefined;
60
86
  }
61
- interface WebhookDeps {
87
+ /** What `bundlePurchase` returns for a bundle the consumer recognises. */
88
+ interface ResolvedBundle {
89
+ /** Final charged price for the whole bundle, in cents. Clamped up to the Stripe card minimum. */
90
+ totalCents: number;
91
+ /** Stripe line-item name, e.g. `"Summer Pack (3 releases, 20% off)"`. */
92
+ productName: string;
62
93
  /**
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.
94
+ * Member release ids. Resolved against `listPublishedReleases()` ids that
95
+ * aren't published are dropped, and a bundle left with none is treated as
96
+ * unavailable rather than charging for an order with no items.
66
97
  */
67
- stripe?: Stripe;
68
- stripeSecret?: string;
69
- webhookSecret: string;
98
+ releaseIds: number[];
99
+ }
100
+ /** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
101
+ interface FulfillSessionDeps {
70
102
  queries: Queries;
71
103
  /**
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
104
+ * The site (alias) this fulfillment path serves. Stripe broadcasts every
105
+ * event to every registered endpoint (and a success URL can be hand-forged),
106
+ * so any session whose `metadata.site` doesn't match is skipped — this is
75
107
  * what lets one Stripe account safely back multiple sites.
76
108
  */
77
109
  site: string;
110
+ }
111
+ /** What `sendPurchaseConfirmation` needs to mail the customer their magic link. */
112
+ interface PurchaseConfirmationDeps {
78
113
  email: EmailProvider;
79
114
  branding: EmailBranding;
80
115
  emailFrom: string;
@@ -99,6 +134,16 @@ interface WebhookDeps {
99
134
  */
100
135
  renderConfirmation?: (args: PurchaseConfirmationArgs) => RenderedEmail | Promise<RenderedEmail>;
101
136
  }
137
+ interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
138
+ /**
139
+ * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
140
+ * at the consumer-app boundary) or a `stripeSecret` string the handler uses
141
+ * to construct one. Pass exactly one — `stripe` wins if both are provided.
142
+ */
143
+ stripe?: Stripe;
144
+ stripeSecret?: string;
145
+ webhookSecret: string;
146
+ }
102
147
  interface DownloadDeps {
103
148
  queries: Queries;
104
149
  storage: StorageProvider;
@@ -126,35 +171,92 @@ declare function createCheckoutHandler(deps: CheckoutDeps): (req: NextRequest) =
126
171
 
127
172
  /**
128
173
  * 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.
174
+ * signature, records the order on `checkout.session.completed` via the shared
175
+ * `fulfillCheckoutSession`, then defers the magic-link email via `after()` so
176
+ * Stripe sees a fast 200.
131
177
  *
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.
178
+ * The handler is not the only fulfillment path — a consumer's success page can
179
+ * call `fulfillCheckoutSession` directly rather than waiting on webhook
180
+ * delivery. Whichever runs first writes the row; the other gets
181
+ * `already-recorded` and skips the email, so the customer is mailed exactly
182
+ * once no matter who wins. Every non-`created` outcome answers Stripe with
183
+ * `{ received: true }` so it doesn't retry.
184
+ */
185
+ declare function createStripeWebhookHandler(deps: WebhookDeps): (req: NextRequest) => Promise<Response>;
186
+
187
+ type FulfillSessionResult =
188
+ /** This call wrote the order row. The caller owns sending the confirmation email. */
189
+ {
190
+ status: "created";
191
+ order: OrderWithItems;
192
+ itemNames: string[];
193
+ customerEmail: string;
194
+ totalCents: number;
195
+ }
196
+ /**
197
+ * The order was already on disk — either the pre-flight lookup found it or a
198
+ * concurrent writer won the unique-constraint race. `order` is null only if
199
+ * the re-read after a lost race somehow came back empty.
200
+ */
201
+ | {
202
+ status: "already-recorded";
203
+ order: OrderWithItems | null;
204
+ }
205
+ /** Nothing was written and nothing is owed. */
206
+ | {
207
+ status: "skipped";
208
+ reason: "site-mismatch" | "no-items" | "unpaid";
209
+ };
210
+ /**
211
+ * Turn a completed Stripe Checkout Session into a persisted order, idempotently.
212
+ *
213
+ * This is the single fulfillment path shared by the Stripe webhook and any
214
+ * consumer that wants to fulfill synchronously — e.g. a post-checkout success
215
+ * page that would otherwise have to wait on webhook delivery to show download
216
+ * links. Whoever calls first writes the row; everyone after gets
217
+ * `already-recorded`. That guarantee rests on the unique constraint over
218
+ * `stripeSessionId` inside `recordCompletedOrder`, not on call ordering, so
219
+ * concurrent callers are safe.
220
+ *
221
+ * Only `status: "created"` should trigger side effects (the confirmation
222
+ * email) — that status is returned to exactly one caller per session.
136
223
  *
137
224
  * ### Published-only resolution (gotcha)
138
225
  *
139
- * The handler resolves the release/track IDs from `session.metadata` against
226
+ * Release/track IDs from `session.metadata` are resolved against
140
227
  * `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.
228
+ * fulfillment time is silently dropped from the persisted order's line items —
229
+ * the customer is still charged, but the order row doesn't include the
230
+ * unpublished item.
144
231
  *
145
232
  * In the canonical flow this can't happen: `createCheckoutHandler` filters on
146
233
  * 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
234
+ * Session. The only ways an unpublished ID can reach here are:
235
+ * - an admin unpublishes between session-create and fulfillment
149
236
  * (a several-second race window),
150
237
  * - a consumer bypasses `createCheckoutHandler` and creates Stripe sessions
151
238
  * directly with their own metadata.
152
239
  *
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.
240
+ * If either is a realistic concern, swap the resolution call here to
241
+ * `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be aware
242
+ * that doing so will surface unpublished items in admin order views.
156
243
  */
157
- declare function createStripeWebhookHandler(deps: WebhookDeps): (req: NextRequest) => Promise<Response>;
244
+ declare function fulfillCheckoutSession(session: Stripe.Checkout.Session, deps: FulfillSessionDeps): Promise<FulfillSessionResult>;
245
+ /**
246
+ * Render and send the post-purchase confirmation email carrying the durable
247
+ * magic link back to the customer's downloads.
248
+ *
249
+ * Errors are logged, never thrown: the order is already recorded and paid for,
250
+ * so a mail-provider hiccup must not fail the caller (a webhook returning 500
251
+ * would make Stripe retry a fulfillment that already succeeded). Callers
252
+ * typically wrap this in `after()` so the response isn't blocked on it.
253
+ */
254
+ declare function sendPurchaseConfirmation(args: {
255
+ orderId: number;
256
+ customerEmail: string;
257
+ itemNames: string[];
258
+ totalCents: number;
259
+ }, deps: PurchaseConfirmationDeps): Promise<void>;
158
260
 
159
261
  interface RouteContext$2 {
160
262
  params: Promise<{
@@ -237,4 +339,4 @@ declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextR
237
339
  */
238
340
  declare function createSwZipFallbackHandler(): () => Response;
239
341
 
240
- export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler };
342
+ export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type ResolvedBundle, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };