@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 +90 -0
- package/dist/index.d.ts +132 -30
- package/dist/index.js +222 -125
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/checkout.ts +132 -8
- package/src/fulfill.ts +266 -0
- package/src/index.ts +6 -0
- package/src/types.ts +62 -12
- package/src/webhook.ts +24 -198
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
* every registered endpoint
|
|
74
|
-
*
|
|
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
|
|
130
|
-
* the magic-link email via `after()` so
|
|
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
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
-
*
|
|
226
|
+
* Release/track IDs from `session.metadata` are resolved against
|
|
140
227
|
* `queries.listPublishedReleases()`. Anything not in the published catalog at
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
148
|
-
* - an admin unpublishes between session-create and
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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 };
|