diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index b156505e..c64cd1cb 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -22,6 +22,7 @@ import { DatabaseInsertError, InternalServerError, UnauthenticatedError, + ValidationError, } from "common/errors/index.js"; import { Modules } from "common/modules.js"; import { AppRoles } from "common/roles.js"; @@ -32,8 +33,17 @@ import { } from "common/types/stripe.js"; import { FastifyPluginAsync } from "fastify"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; +import stripe, { Stripe } from "stripe"; +import rawbody from "fastify-raw-body"; +import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { + await fastify.register(rawbody, { + field: "rawBody", + global: false, + runFirst: true, + }); fastify.withTypeProvider().get( "/paymentLinks", { @@ -165,6 +175,156 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { reply.status(201).send({ id: linkId, link: url }); }, ); + fastify.post( + "/webhook", + { + config: { rawBody: true }, + schema: withTags(["Stripe"], { + summary: + "Stripe webhook handler to track when Stripe payment links are used.", + hide: true, + }), + }, + async (request, reply) => { + let event: Stripe.Event; + if (!request.rawBody) { + throw new ValidationError({ message: "Could not get raw body." }); + } + try { + const sig = request.headers["stripe-signature"]; + if (!sig || typeof sig !== "string") { + throw new Error("Missing or invalid Stripe signature"); + } + const secretApiConfig = + (await getSecretValue( + fastify.secretsManagerClient, + genericConfig.ConfigSecretName, + )) || {}; + if (!secretApiConfig) { + throw new InternalServerError({ + message: "Could not connect to Stripe.", + }); + } + event = stripe.webhooks.constructEvent( + request.rawBody, + sig, + secretApiConfig.stripe_links_endpoint_secret as string, + ); + } catch (err: unknown) { + if (err instanceof BaseError) { + throw err; + } + throw new ValidationError({ + message: "Stripe webhook could not be validated.", + }); + } + switch (event.type) { + case "checkout.session.completed": + if (event.data.object.payment_link) { + const eventId = event.id; + const paymentAmount = event.data.object.amount_total; + const paymentCurrency = event.data.object.currency; + const { email, name } = event.data.object.customer_details || { + email: null, + name: null, + }; + const paymentLinkId = event.data.object.payment_link.toString(); + if (!paymentLinkId || !paymentCurrency || !paymentAmount) { + request.log.info("Missing required fields."); + return reply + .code(200) + .send({ handled: false, requestId: request.id }); + } + const response = await fastify.dynamoClient.send( + new QueryCommand({ + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + KeyConditionExpression: "linkId = :linkId", + ExpressionAttributeValues: { + ":linkId": { S: paymentLinkId }, + }, + }), + ); + if (!response) { + throw new DatabaseFetchError({ + message: "Could not check for payment link in table.", + }); + } + if (!response.Items || response.Items?.length !== 1) { + return reply.status(200).send({ + handled: false, + requestId: request.id, + }); + } + const unmarshalledEntry = unmarshall(response.Items[0]) as { + userId: string; + invoiceId: string; + }; + if (!unmarshalledEntry.userId || !unmarshalledEntry.invoiceId) { + return reply.status(200).send({ + handled: false, + requestId: request.id, + }); + } + const withCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: paymentCurrency.toUpperCase(), + }) + .formatToParts(paymentAmount / 100) + .map((val) => val.value) + .join(""); + request.log.info( + `Registered payment of ${withCurrency} by ${name} (${email}) for payment link ${paymentLinkId} invoice ID ${unmarshalledEntry.invoiceId}).`, + ); + if (unmarshalledEntry.userId.includes("@")) { + request.log.info( + `Sending email to ${unmarshalledEntry.userId}...`, + ); + const sqsPayload: SQSPayload = + { + function: AvailableSQSFunctions.EmailNotifications, + metadata: { + initiator: eventId, + reqId: request.id, + }, + payload: { + to: [unmarshalledEntry.userId], + subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, + content: `Received payment of ${withCurrency} by ${name} (${email}) for Invoice ${unmarshalledEntry.invoiceId}. Please contact treasurer@acm.illinois.edu with any questions.`, + }, + }; + if (!fastify.sqsClient) { + fastify.sqsClient = new SQSClient({ + region: genericConfig.AwsRegion, + }); + } + const result = await fastify.sqsClient.send( + new SendMessageCommand({ + QueueUrl: fastify.environmentConfig.SqsQueueUrl, + MessageBody: JSON.stringify(sqsPayload), + }), + ); + return reply.status(200).send({ + handled: true, + requestId: request.id, + queueId: result.MessageId, + }); + } + return reply.status(200).send({ + handled: true, + requestId: request.id, + }); + } + return reply + .code(200) + .send({ handled: false, requestId: request.id }); + + default: + request.log.warn(`Unhandled event type: ${event.type}`); + } + return reply.code(200).send({ handled: false, requestId: request.id }); + }, + ); }; export default stripeRoutes; diff --git a/src/api/sqs/index.ts b/src/api/sqs/index.ts index 2c990d77..8f755edc 100644 --- a/src/api/sqs/index.ts +++ b/src/api/sqs/index.ts @@ -60,6 +60,10 @@ export const handler = middy() { sqsMessageId: record.messageId }, parsedBody.toString(), ); + logger.error( + { sqsMessageId: record.messageId }, + parsedBody.errors.toString(), + ); throw new ValidationError({ message: "Could not parse SQS payload", }); diff --git a/src/common/config.ts b/src/common/config.ts index 14937cf5..0076dfb9 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -147,6 +147,7 @@ export type SecretConfig = { apple_signing_cert_base64: string; stripe_secret_key: string; stripe_endpoint_secret: string; + stripe_links_endpoint_secret: string; redis_url: string; }; diff --git a/tests/live/ical.test.ts b/tests/live/ical.test.ts index 3e0eca8c..f7cc2c6d 100644 --- a/tests/live/ical.test.ts +++ b/tests/live/ical.test.ts @@ -36,9 +36,6 @@ describe( const response = await fetchWithRateLimit( `${baseEndpoint}/api/v1/ical/${org}`, ); - if (!response.ok) { - console.log(response); - } expect(response.status).toBe(200); expect(response.headers.get("Content-Disposition")).toEqual( 'attachment; filename="calendar.ics"', diff --git a/tests/unit/secret.testdata.ts b/tests/unit/secret.testdata.ts index 3357a1a9..f64836f6 100644 --- a/tests/unit/secret.testdata.ts +++ b/tests/unit/secret.testdata.ts @@ -6,6 +6,9 @@ const secretObject = { discord_bot_token: "12345", entra_id_private_key: "", entra_id_thumbprint: "", + stripe_secret_key: "sk_test_12345", + stripe_endpoint_secret: "whsec_01234", + stripe_links_endpoint_secret: "whsec_56789", acm_passkit_signerCert_base64: "", acm_passkit_signerKey_base64: "", apple_signing_cert_base64: "", diff --git a/tests/unit/webhooks.test.ts b/tests/unit/webhooks.test.ts new file mode 100644 index 00000000..eb058b7f --- /dev/null +++ b/tests/unit/webhooks.test.ts @@ -0,0 +1,175 @@ +import { afterAll, expect, test, beforeEach, vi, describe } from "vitest"; +import init from "../../src/api/index.js"; +import { mockClient } from "aws-sdk-client-mock"; +import { secretObject } from "./secret.testdata.js"; +import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; +import supertest from "supertest"; +import { v4 as uuidv4 } from "uuid"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import stripe from "stripe"; +import { genericConfig } from "../../src/common/config.js"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; + +const ddbMock = mockClient(DynamoDBClient); +const sqsMock = mockClient(SQSClient); + +const linkId = uuidv4(); +const paymentLinkMock = { + id: linkId, + url: `https://buy.stripe.com/${linkId}`, +}; + +const app = await init(); +describe("Test Stripe webhooks", async () => { + test("Stripe Payment Link skips non-existing links", async () => { + const queueId = uuidv4(); + sqsMock.on(SendMessageCommand).rejects(); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + }) + .resolvesOnce({ + Items: [], + }); + const payload = JSON.stringify({ + type: "checkout.session.completed", + id: "evt_abc123", + data: { + object: { + payment_link: linkId, + amount_total: 10000, + currency: "usd", + customer_details: { + name: "Test User", + email: "testuser@example.com", + }, + }, + }, + }); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/stripe/webhook") + .set("content-type", "application/json") + .set( + "stripe-signature", + stripe.webhooks.generateTestHeaderString({ + payload, + secret: secretObject.stripe_links_endpoint_secret, + }), + ) + .send(payload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + handled: false, + }), + ); + }); + test("Stripe Payment Link validates webhook signature", async () => { + const queueId = uuidv4(); + sqsMock.on(SendMessageCommand).rejects(); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + }) + .rejects(); + const payload = JSON.stringify({ + type: "checkout.session.completed", + id: "evt_abc123", + data: { + object: { + payment_link: linkId, + amount_total: 10000, + currency: "usd", + customer_details: { + name: "Test User", + email: "testuser@example.com", + }, + }, + }, + }); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/stripe/webhook") + .set("content-type", "application/json") + .set( + "stripe-signature", + stripe.webhooks.generateTestHeaderString({ payload, secret: "nah" }), + ) + .send(payload); + expect(response.statusCode).toBe(400); + expect(response.body).toStrictEqual({ + error: true, + id: 104, + message: "Stripe webhook could not be validated.", + name: "ValidationError", + }); + }); + test("Stripe Payment Link emails successfully", async () => { + const queueId = uuidv4(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: queueId }); + ddbMock + .on(QueryCommand, { + TableName: genericConfig.StripeLinksDynamoTableName, + IndexName: "LinkIdIndex", + }) + .resolves({ + Count: 1, + Items: [ + marshall({ + linkId, + userId: "testUser@illinois.edu", + url: paymentLinkMock.url, + active: true, + invoiceId: "ACM102", + amount: 100, + createdAt: "2025-02-09T17:11:30.762Z", + }), + ], + }); + const payload = JSON.stringify({ + type: "checkout.session.completed", + id: "evt_abc123", + data: { + object: { + payment_link: linkId, + amount_total: 10000, + currency: "usd", + customer_details: { + name: "Test User", + email: "testuser@example.com", + }, + }, + }, + }); + await app.ready(); + const response = await supertest(app.server) + .post("/api/v1/stripe/webhook") + .set("content-type", "application/json") + .set( + "stripe-signature", + stripe.webhooks.generateTestHeaderString({ + payload, + secret: secretObject.stripe_links_endpoint_secret, + }), + ) + .send(payload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + handled: true, + queueId, + }), + ); + }); + afterAll(async () => { + await app.close(); + }); + beforeEach(() => { + (app as any).nodeCache.flushAll(); + ddbMock.reset(); + sqsMock.reset(); + }); +});