Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions src/api/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<FastifyZodOpenApiTypeProvider>().get(
"/paymentLinks",
{
Expand Down Expand Up @@ -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<AvailableSQSFunctions.EmailNotifications> =
{
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 [email protected] 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;
4 changes: 4 additions & 0 deletions src/api/sqs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
1 change: 1 addition & 0 deletions src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
3 changes: 0 additions & 3 deletions tests/live/ical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/secret.testdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
175 changes: 175 additions & 0 deletions tests/unit/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
},
},
},
});
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: "[email protected]",
},
},
},
});
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: "[email protected]",
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: "[email protected]",
},
},
},
});
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();
});
});
Loading