Skip to content

Commit 57208e3

Browse files
authored
Stripe webhook for payment link tracking (#163)
1 parent 874a882 commit 57208e3

File tree

6 files changed

+343
-3
lines changed

6 files changed

+343
-3
lines changed

src/api/routes/stripe.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
DatabaseInsertError,
2323
InternalServerError,
2424
UnauthenticatedError,
25+
ValidationError,
2526
} from "common/errors/index.js";
2627
import { Modules } from "common/modules.js";
2728
import { AppRoles } from "common/roles.js";
@@ -32,8 +33,17 @@ import {
3233
} from "common/types/stripe.js";
3334
import { FastifyPluginAsync } from "fastify";
3435
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
36+
import stripe, { Stripe } from "stripe";
37+
import rawbody from "fastify-raw-body";
38+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
39+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
3540

3641
const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
42+
await fastify.register(rawbody, {
43+
field: "rawBody",
44+
global: false,
45+
runFirst: true,
46+
});
3747
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
3848
"/paymentLinks",
3949
{
@@ -165,6 +175,156 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
165175
reply.status(201).send({ id: linkId, link: url });
166176
},
167177
);
178+
fastify.post(
179+
"/webhook",
180+
{
181+
config: { rawBody: true },
182+
schema: withTags(["Stripe"], {
183+
summary:
184+
"Stripe webhook handler to track when Stripe payment links are used.",
185+
hide: true,
186+
}),
187+
},
188+
async (request, reply) => {
189+
let event: Stripe.Event;
190+
if (!request.rawBody) {
191+
throw new ValidationError({ message: "Could not get raw body." });
192+
}
193+
try {
194+
const sig = request.headers["stripe-signature"];
195+
if (!sig || typeof sig !== "string") {
196+
throw new Error("Missing or invalid Stripe signature");
197+
}
198+
const secretApiConfig =
199+
(await getSecretValue(
200+
fastify.secretsManagerClient,
201+
genericConfig.ConfigSecretName,
202+
)) || {};
203+
if (!secretApiConfig) {
204+
throw new InternalServerError({
205+
message: "Could not connect to Stripe.",
206+
});
207+
}
208+
event = stripe.webhooks.constructEvent(
209+
request.rawBody,
210+
sig,
211+
secretApiConfig.stripe_links_endpoint_secret as string,
212+
);
213+
} catch (err: unknown) {
214+
if (err instanceof BaseError) {
215+
throw err;
216+
}
217+
throw new ValidationError({
218+
message: "Stripe webhook could not be validated.",
219+
});
220+
}
221+
switch (event.type) {
222+
case "checkout.session.completed":
223+
if (event.data.object.payment_link) {
224+
const eventId = event.id;
225+
const paymentAmount = event.data.object.amount_total;
226+
const paymentCurrency = event.data.object.currency;
227+
const { email, name } = event.data.object.customer_details || {
228+
email: null,
229+
name: null,
230+
};
231+
const paymentLinkId = event.data.object.payment_link.toString();
232+
if (!paymentLinkId || !paymentCurrency || !paymentAmount) {
233+
request.log.info("Missing required fields.");
234+
return reply
235+
.code(200)
236+
.send({ handled: false, requestId: request.id });
237+
}
238+
const response = await fastify.dynamoClient.send(
239+
new QueryCommand({
240+
TableName: genericConfig.StripeLinksDynamoTableName,
241+
IndexName: "LinkIdIndex",
242+
KeyConditionExpression: "linkId = :linkId",
243+
ExpressionAttributeValues: {
244+
":linkId": { S: paymentLinkId },
245+
},
246+
}),
247+
);
248+
if (!response) {
249+
throw new DatabaseFetchError({
250+
message: "Could not check for payment link in table.",
251+
});
252+
}
253+
if (!response.Items || response.Items?.length !== 1) {
254+
return reply.status(200).send({
255+
handled: false,
256+
requestId: request.id,
257+
});
258+
}
259+
const unmarshalledEntry = unmarshall(response.Items[0]) as {
260+
userId: string;
261+
invoiceId: string;
262+
};
263+
if (!unmarshalledEntry.userId || !unmarshalledEntry.invoiceId) {
264+
return reply.status(200).send({
265+
handled: false,
266+
requestId: request.id,
267+
});
268+
}
269+
const withCurrency = new Intl.NumberFormat("en-US", {
270+
style: "currency",
271+
currency: paymentCurrency.toUpperCase(),
272+
})
273+
.formatToParts(paymentAmount / 100)
274+
.map((val) => val.value)
275+
.join("");
276+
request.log.info(
277+
`Registered payment of ${withCurrency} by ${name} (${email}) for payment link ${paymentLinkId} invoice ID ${unmarshalledEntry.invoiceId}).`,
278+
);
279+
if (unmarshalledEntry.userId.includes("@")) {
280+
request.log.info(
281+
`Sending email to ${unmarshalledEntry.userId}...`,
282+
);
283+
const sqsPayload: SQSPayload<AvailableSQSFunctions.EmailNotifications> =
284+
{
285+
function: AvailableSQSFunctions.EmailNotifications,
286+
metadata: {
287+
initiator: eventId,
288+
reqId: request.id,
289+
},
290+
payload: {
291+
to: [unmarshalledEntry.userId],
292+
subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`,
293+
content: `Received payment of ${withCurrency} by ${name} (${email}) for Invoice ${unmarshalledEntry.invoiceId}. Please contact [email protected] with any questions.`,
294+
},
295+
};
296+
if (!fastify.sqsClient) {
297+
fastify.sqsClient = new SQSClient({
298+
region: genericConfig.AwsRegion,
299+
});
300+
}
301+
const result = await fastify.sqsClient.send(
302+
new SendMessageCommand({
303+
QueueUrl: fastify.environmentConfig.SqsQueueUrl,
304+
MessageBody: JSON.stringify(sqsPayload),
305+
}),
306+
);
307+
return reply.status(200).send({
308+
handled: true,
309+
requestId: request.id,
310+
queueId: result.MessageId,
311+
});
312+
}
313+
return reply.status(200).send({
314+
handled: true,
315+
requestId: request.id,
316+
});
317+
}
318+
return reply
319+
.code(200)
320+
.send({ handled: false, requestId: request.id });
321+
322+
default:
323+
request.log.warn(`Unhandled event type: ${event.type}`);
324+
}
325+
return reply.code(200).send({ handled: false, requestId: request.id });
326+
},
327+
);
168328
};
169329

170330
export default stripeRoutes;

src/api/sqs/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const handler = middy()
6060
{ sqsMessageId: record.messageId },
6161
parsedBody.toString(),
6262
);
63+
logger.error(
64+
{ sqsMessageId: record.messageId },
65+
parsedBody.errors.toString(),
66+
);
6367
throw new ValidationError({
6468
message: "Could not parse SQS payload",
6569
});

src/common/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export type SecretConfig = {
147147
apple_signing_cert_base64: string;
148148
stripe_secret_key: string;
149149
stripe_endpoint_secret: string;
150+
stripe_links_endpoint_secret: string;
150151
redis_url: string;
151152
};
152153

tests/live/ical.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ describe(
3636
const response = await fetchWithRateLimit(
3737
`${baseEndpoint}/api/v1/ical/${org}`,
3838
);
39-
if (!response.ok) {
40-
console.log(response);
41-
}
4239
expect(response.status).toBe(200);
4340
expect(response.headers.get("Content-Disposition")).toEqual(
4441
'attachment; filename="calendar.ics"',

tests/unit/secret.testdata.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const secretObject = {
66
discord_bot_token: "12345",
77
entra_id_private_key: "",
88
entra_id_thumbprint: "",
9+
stripe_secret_key: "sk_test_12345",
10+
stripe_endpoint_secret: "whsec_01234",
11+
stripe_links_endpoint_secret: "whsec_56789",
912
acm_passkit_signerCert_base64: "",
1013
acm_passkit_signerKey_base64: "",
1114
apple_signing_cert_base64: "",

tests/unit/webhooks.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { afterAll, expect, test, beforeEach, vi, describe } from "vitest";
2+
import init from "../../src/api/index.js";
3+
import { mockClient } from "aws-sdk-client-mock";
4+
import { secretObject } from "./secret.testdata.js";
5+
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
6+
import supertest from "supertest";
7+
import { v4 as uuidv4 } from "uuid";
8+
import { marshall } from "@aws-sdk/util-dynamodb";
9+
import stripe from "stripe";
10+
import { genericConfig } from "../../src/common/config.js";
11+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
12+
13+
const ddbMock = mockClient(DynamoDBClient);
14+
const sqsMock = mockClient(SQSClient);
15+
16+
const linkId = uuidv4();
17+
const paymentLinkMock = {
18+
id: linkId,
19+
url: `https://buy.stripe.com/${linkId}`,
20+
};
21+
22+
const app = await init();
23+
describe("Test Stripe webhooks", async () => {
24+
test("Stripe Payment Link skips non-existing links", async () => {
25+
const queueId = uuidv4();
26+
sqsMock.on(SendMessageCommand).rejects();
27+
ddbMock
28+
.on(QueryCommand, {
29+
TableName: genericConfig.StripeLinksDynamoTableName,
30+
IndexName: "LinkIdIndex",
31+
})
32+
.resolvesOnce({
33+
Items: [],
34+
});
35+
const payload = JSON.stringify({
36+
type: "checkout.session.completed",
37+
id: "evt_abc123",
38+
data: {
39+
object: {
40+
payment_link: linkId,
41+
amount_total: 10000,
42+
currency: "usd",
43+
customer_details: {
44+
name: "Test User",
45+
46+
},
47+
},
48+
},
49+
});
50+
await app.ready();
51+
const response = await supertest(app.server)
52+
.post("/api/v1/stripe/webhook")
53+
.set("content-type", "application/json")
54+
.set(
55+
"stripe-signature",
56+
stripe.webhooks.generateTestHeaderString({
57+
payload,
58+
secret: secretObject.stripe_links_endpoint_secret,
59+
}),
60+
)
61+
.send(payload);
62+
expect(response.statusCode).toBe(200);
63+
expect(response.body).toEqual(
64+
expect.objectContaining({
65+
handled: false,
66+
}),
67+
);
68+
});
69+
test("Stripe Payment Link validates webhook signature", async () => {
70+
const queueId = uuidv4();
71+
sqsMock.on(SendMessageCommand).rejects();
72+
ddbMock
73+
.on(QueryCommand, {
74+
TableName: genericConfig.StripeLinksDynamoTableName,
75+
IndexName: "LinkIdIndex",
76+
})
77+
.rejects();
78+
const payload = JSON.stringify({
79+
type: "checkout.session.completed",
80+
id: "evt_abc123",
81+
data: {
82+
object: {
83+
payment_link: linkId,
84+
amount_total: 10000,
85+
currency: "usd",
86+
customer_details: {
87+
name: "Test User",
88+
89+
},
90+
},
91+
},
92+
});
93+
await app.ready();
94+
const response = await supertest(app.server)
95+
.post("/api/v1/stripe/webhook")
96+
.set("content-type", "application/json")
97+
.set(
98+
"stripe-signature",
99+
stripe.webhooks.generateTestHeaderString({ payload, secret: "nah" }),
100+
)
101+
.send(payload);
102+
expect(response.statusCode).toBe(400);
103+
expect(response.body).toStrictEqual({
104+
error: true,
105+
id: 104,
106+
message: "Stripe webhook could not be validated.",
107+
name: "ValidationError",
108+
});
109+
});
110+
test("Stripe Payment Link emails successfully", async () => {
111+
const queueId = uuidv4();
112+
sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId });
113+
ddbMock
114+
.on(QueryCommand, {
115+
TableName: genericConfig.StripeLinksDynamoTableName,
116+
IndexName: "LinkIdIndex",
117+
})
118+
.resolves({
119+
Count: 1,
120+
Items: [
121+
marshall({
122+
linkId,
123+
userId: "[email protected]",
124+
url: paymentLinkMock.url,
125+
active: true,
126+
invoiceId: "ACM102",
127+
amount: 100,
128+
createdAt: "2025-02-09T17:11:30.762Z",
129+
}),
130+
],
131+
});
132+
const payload = JSON.stringify({
133+
type: "checkout.session.completed",
134+
id: "evt_abc123",
135+
data: {
136+
object: {
137+
payment_link: linkId,
138+
amount_total: 10000,
139+
currency: "usd",
140+
customer_details: {
141+
name: "Test User",
142+
143+
},
144+
},
145+
},
146+
});
147+
await app.ready();
148+
const response = await supertest(app.server)
149+
.post("/api/v1/stripe/webhook")
150+
.set("content-type", "application/json")
151+
.set(
152+
"stripe-signature",
153+
stripe.webhooks.generateTestHeaderString({
154+
payload,
155+
secret: secretObject.stripe_links_endpoint_secret,
156+
}),
157+
)
158+
.send(payload);
159+
expect(response.statusCode).toBe(200);
160+
expect(response.body).toEqual(
161+
expect.objectContaining({
162+
handled: true,
163+
queueId,
164+
}),
165+
);
166+
});
167+
afterAll(async () => {
168+
await app.close();
169+
});
170+
beforeEach(() => {
171+
(app as any).nodeCache.flushAll();
172+
ddbMock.reset();
173+
sqsMock.reset();
174+
});
175+
});

0 commit comments

Comments
 (0)