@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/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
|
-
|
|
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
|
|
73
|
-
* every registered endpoint
|
|
74
|
-
*
|
|
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
|
|
130
|
-
* the magic-link email via `after()` so
|
|
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
|
-
*
|
|
133
|
-
*
|
|
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
|
-
*
|
|
187
|
+
* Release/track IDs from `session.metadata` are resolved against
|
|
140
188
|
* `queries.listPublishedReleases()`. Anything not in the published catalog at
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
|
148
|
-
* - an admin unpublishes between session-create and
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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
|
|
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 (!
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|