From a2a795c9ceb42304182019e06d8e9a12022fe7aa Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 10 Jul 2025 22:53:17 -0400 Subject: [PATCH 1/4] Add payment method info to the final payment notification email --- src/api/functions/stripe.ts | 115 +++++++++++++++++++++++++++++++++++- src/api/routes/stripe.ts | 45 ++++++++++++++ src/common/errors/index.ts | 12 +++- 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 5895ee8d..834c2149 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -1,4 +1,5 @@ -import { InternalServerError } from "common/errors/index.js"; +import { InternalServerError, ValidationError } from "common/errors/index.js"; +import { capitalizeFirstLetter } from "common/types/roomRequest.js"; import Stripe from "stripe"; export type StripeLinkCreateParams = { @@ -128,3 +129,115 @@ export const deactivateStripeProduct = async ({ active: false, }); }; + +export const getStripePaymentIntentData = async ({ + stripeClient, + paymentIntentId, + stripeApiKey, +}: { + paymentIntentId: string; + stripeApiKey: string; + stripeClient?: Stripe; +}) => { + const stripe = stripeClient || new Stripe(stripeApiKey); + return await stripe.paymentIntents.retrieve(paymentIntentId); +}; + +export const getPaymentMethodForPaymentIntent = async ({ + paymentIntentId, + stripeApiKey, +}: { + paymentIntentId: string; + stripeApiKey: string; +}) => { + const stripe = new Stripe(stripeApiKey); + const paymentIntentData = await getStripePaymentIntentData({ + paymentIntentId, + stripeApiKey, + stripeClient: stripe, + }); + if (!paymentIntentData) { + throw new InternalServerError({ + internalLog: `Could not find payment intent data for payment intent ID "${paymentIntentId}".`, + }); + } + const paymentMethodId = paymentIntentData.payment_method?.toString(); + if (!paymentMethodId) { + throw new InternalServerError({ + internalLog: `Could not find payment method ID for payment intent ID "${paymentIntentId}".`, + }); + } + const paymentMethodData = + await stripe.paymentMethods.retrieve(paymentMethodId); + if (!paymentMethodData) { + throw new InternalServerError({ + internalLog: `Could not find payment method data for payment intent ID "${paymentIntentId}".`, + }); + } + return paymentMethodData; +}; + +export const supportedStripePaymentMethods = [ + "us_bank_account", + "card", + "card_present", +] as const; +export type SupportedStripePaymentMethod = + (typeof supportedStripePaymentMethods)[number]; +export const paymentMethodTypeToFriendlyName: Record< + SupportedStripePaymentMethod, + string +> = { + us_bank_account: "ACH Direct Debit", + card: "Credit/Debit Card", + card_present: "Credit/Debit Card (Card Present)", +}; + +export const cardBrandMap: Record = { + amex: "American Express", + american_express: "American Express", + cartes_bancaires: "Cartes Bancaires", + diners: "Diners Club", + diners_club: "Diners Club", + discover: "Discover", + eftpos_au: "EFTPOS Australia", + eftpos_australia: "EFTPOS Australia", + interac: "Interac", + jcb: "JCB", + link: "Link", + mastercard: "Mastercard", + unionpay: "UnionPay", + visa: "Visa", + unknown: "Unknown Brand", + other: "Unknown Brand", +}; + +export const getPaymentMethodDescriptionString = ({ + paymentMethod, + paymentMethodType, +}: { + paymentMethod: Stripe.PaymentMethod; + paymentMethodType: SupportedStripePaymentMethod; +}) => { + const friendlyName = paymentMethodTypeToFriendlyName[paymentMethodType]; + switch (paymentMethodType) { + case "us_bank_account": + const bankData = paymentMethod[paymentMethodType]; + if (!bankData) { + return null; + } + return `${friendlyName} (${bankData.bank_name} ${capitalizeFirstLetter(bankData.account_type!)} ${bankData.last4})`; + case "card": + const cardData = paymentMethod[paymentMethodType]; + if (!cardData) { + return null; + } + return `${friendlyName} (${cardBrandMap[cardData.display_brand || "unknown"]} ending in ${cardData.last4}`; + case "card_present": + const cardPresentData = paymentMethod[paymentMethodType]; + if (!cardPresentData) { + return null; + } + return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4}`; + } +}; diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 60c16ee4..9bd13550 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -14,7 +14,12 @@ import { createStripeLink, deactivateStripeLink, deactivateStripeProduct, + getPaymentMethodDescriptionString, + getPaymentMethodForPaymentIntent, + paymentMethodTypeToFriendlyName, StripeLinkCreateParams, + SupportedStripePaymentMethod, + supportedStripePaymentMethods, } from "api/functions/stripe.js"; import { getSecretValue } from "api/plugins/auth.js"; import { @@ -457,6 +462,43 @@ Please ask the payee to try again, perhaps with a different payment method, or c const eventId = event.id; const paymentAmount = event.data.object.amount_total; const paymentCurrency = event.data.object.currency; + const paymentIntentId = + event.data.object.payment_intent?.toString(); + if (!paymentIntentId) { + request.log.warn( + "Could not find payment intent ID in webhook payload!", + ); + throw new ValidationError({ + message: "No payment intent ID found.", + }); + } + const stripeApiKey = fastify.secretConfig.stripe_secret_key; + const paymentMethodData = await getPaymentMethodForPaymentIntent({ + paymentIntentId, + stripeApiKey, + }); + const paymentMethodType = + paymentMethodData.type.toString() as SupportedStripePaymentMethod; + if ( + !supportedStripePaymentMethods.includes( + paymentMethodData.type.toString() as SupportedStripePaymentMethod, + ) + ) { + throw new InternalServerError({ + internalLog: `Unknown payment method type ${paymentMethodData.type}!`, + }); + } + const paymentMethodDescriptionData = + paymentMethodData[paymentMethodType]; + if (!paymentMethodDescriptionData) { + throw new InternalServerError({ + internalLog: `Unknown payment method type ${paymentMethodData.type}!`, + }); + } + const paymentMethodString = getPaymentMethodDescriptionString({ + paymentMethod: paymentMethodData, + paymentMethodType, + }); const { email, name } = event.data.object.customer_details || { email: null, name: null, @@ -581,6 +623,9 @@ Please contact Officer Board with any questions. subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`, content: ` ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}). + +${paymentMethodString ? `\nPayment method: ${paymentMethodString}.\n` : ""} + ${paidInFull ? "\nThis invoice should now be considered settled.\n" : ""} Please contact Officer Board with any questions.`, callToActionButton: { diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts index 7e2f400e..ca715f92 100644 --- a/src/common/errors/index.ts +++ b/src/common/errors/index.ts @@ -3,6 +3,7 @@ interface BaseErrorParams { id: number; message: string; httpStatusCode: number; + internalLog?: string; } export abstract class BaseError extends Error { @@ -14,20 +15,24 @@ export abstract class BaseError extends Error { public httpStatusCode: number; - constructor({ name, id, message, httpStatusCode }: BaseErrorParams) { + public internalLog: string | undefined; + + constructor({ name, id, message, httpStatusCode, internalLog }: BaseErrorParams) { super(message || name || "Error"); this.name = name; this.id = id; this.message = message; this.httpStatusCode = httpStatusCode; + this.internalLog = internalLog; if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } } toString() { - return `Error ${this.id} (${this.name}): ${this.message}\n\n${this.stack}`; + return `Error ${this.id} (${this.name}): ${this.message}${this.internalLog ? `\n\nInternal Message: ${this.internalLog}` : ''}\n\n${this.stack}`; } + toJson() { return { error: true, @@ -67,7 +72,7 @@ export class UnauthenticatedError extends BaseError<"UnauthenticatedError"> { } export class InternalServerError extends BaseError<"InternalServerError"> { - constructor({ message }: { message?: string } = {}) { + constructor({ message, internalLog }: { message?: string, internalLog?: string } = {}) { super({ name: "InternalServerError", id: 100, @@ -75,6 +80,7 @@ export class InternalServerError extends BaseError<"InternalServerError"> { message || "An internal server error occurred. Please try again or contact support.", httpStatusCode: 500, + internalLog }); } } From 51abbc06861d4eb5682e28a1397f7d1eda843409 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 10 Jul 2025 23:11:54 -0400 Subject: [PATCH 2/4] FIx unit tests --- tests/unit/webhooks.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit/webhooks.test.ts b/tests/unit/webhooks.test.ts index eb058b7f..45dde407 100644 --- a/tests/unit/webhooks.test.ts +++ b/tests/unit/webhooks.test.ts @@ -19,6 +19,26 @@ const paymentLinkMock = { url: `https://buy.stripe.com/${linkId}`, }; +vi.mock(import("../../src/api/functions/stripe.js"), async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getPaymentMethodForPaymentIntent: vi.fn().mockImplementation(async () => { + return { + type: "us_bank_account", + us_bank_account: { + bank_name: "ACM Bank N.A.", + account_type: "checking", + last4: "0123", + }, + }; + }), + getPaymentMethodDescriptionString: vi.fn().mockImplementation(async () => { + return "Your payment method here."; + }), + }; +}); + const app = await init(); describe("Test Stripe webhooks", async () => { test("Stripe Payment Link skips non-existing links", async () => { @@ -38,6 +58,7 @@ describe("Test Stripe webhooks", async () => { data: { object: { payment_link: linkId, + payment_intent: "pi_123", amount_total: 10000, currency: "usd", customer_details: { @@ -81,6 +102,7 @@ describe("Test Stripe webhooks", async () => { data: { object: { payment_link: linkId, + payment_intent: "pi_123", amount_total: 10000, currency: "usd", customer_details: { @@ -136,6 +158,7 @@ describe("Test Stripe webhooks", async () => { object: { payment_link: linkId, amount_total: 10000, + payment_intent: "pi_123", currency: "usd", customer_details: { name: "Test User", From a599a4492f0f27066c06e73be6932043280208bd Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 10 Jul 2025 23:12:04 -0400 Subject: [PATCH 3/4] Cleanup code --- src/api/functions/stripe.ts | 2 +- src/api/routes/stripe.ts | 13 +++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 834c2149..2dbdd2d0 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -226,7 +226,7 @@ export const getPaymentMethodDescriptionString = ({ if (!bankData) { return null; } - return `${friendlyName} (${bankData.bank_name} ${capitalizeFirstLetter(bankData.account_type!)} ${bankData.last4})`; + return `${friendlyName} (${bankData.bank_name} ${capitalizeFirstLetter(bankData.account_type || "checking")} ${bankData.last4})`; case "card": const cardData = paymentMethod[paymentMethodType]; if (!cardData) { diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index 9bd13550..d11fe029 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -6,10 +6,7 @@ import { } from "@aws-sdk/client-dynamodb"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { withRoles, withTags } from "api/components/index.js"; -import { - buildAuditLogTransactPut, - createAuditLogEntry, -} from "api/functions/auditLog.js"; +import { buildAuditLogTransactPut } from "api/functions/auditLog.js"; import { createStripeLink, deactivateStripeLink, @@ -22,11 +19,7 @@ import { supportedStripePaymentMethods, } from "api/functions/stripe.js"; import { getSecretValue } from "api/plugins/auth.js"; -import { - environmentConfig, - genericConfig, - notificationRecipients, -} from "common/config.js"; +import { genericConfig, notificationRecipients } from "common/config.js"; import { BaseError, DatabaseFetchError, @@ -492,7 +485,7 @@ Please ask the payee to try again, perhaps with a different payment method, or c paymentMethodData[paymentMethodType]; if (!paymentMethodDescriptionData) { throw new InternalServerError({ - internalLog: `Unknown payment method type ${paymentMethodData.type}!`, + internalLog: `No payment method data for ${paymentMethodData.type}!`, }); } const paymentMethodString = getPaymentMethodDescriptionString({ From f4abafd2d14a7eff46c14ca4c47a85d39199ce8c Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Thu, 10 Jul 2025 23:21:59 -0400 Subject: [PATCH 4/4] fix string and live test --- src/api/functions/stripe.ts | 4 ++-- tests/live/clearSession.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 2dbdd2d0..67f262c9 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -232,12 +232,12 @@ export const getPaymentMethodDescriptionString = ({ if (!cardData) { return null; } - return `${friendlyName} (${cardBrandMap[cardData.display_brand || "unknown"]} ending in ${cardData.last4}`; + return `${friendlyName} (${cardBrandMap[cardData.display_brand || "unknown"]} ending in ${cardData.last4})`; case "card_present": const cardPresentData = paymentMethod[paymentMethodType]; if (!cardPresentData) { return null; } - return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4}`; + return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4})`; } }; diff --git a/tests/live/clearSession.test.ts b/tests/live/clearSession.test.ts index 77a49a2f..68870593 100644 --- a/tests/live/clearSession.test.ts +++ b/tests/live/clearSession.test.ts @@ -29,6 +29,8 @@ describe("Session clearing tests", async () => { }); expect(clearResponse.status).toBe(201); // token should be revoked + // add a sleep because delay shouldn't fail the pipeline + await new Promise((r) => setTimeout(r, 1000)); const responseFail = await fetch(`${baseEndpoint}/api/v1/protected`, { method: "GET", headers: {