diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 5895ee8d..67f262c9 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 || "checking")} ${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..d11fe029 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -6,22 +6,20 @@ 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, deactivateStripeProduct, + getPaymentMethodDescriptionString, + getPaymentMethodForPaymentIntent, + paymentMethodTypeToFriendlyName, StripeLinkCreateParams, + SupportedStripePaymentMethod, + 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, @@ -457,6 +455,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: `No payment method data for ${paymentMethodData.type}!`, + }); + } + const paymentMethodString = getPaymentMethodDescriptionString({ + paymentMethod: paymentMethodData, + paymentMethodType, + }); const { email, name } = event.data.object.customer_details || { email: null, name: null, @@ -581,6 +616,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 }); } } 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: { 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",