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
115 changes: 114 additions & 1 deletion src/api/functions/stripe.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, string> = {
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})`;
}
};
56 changes: 47 additions & 9 deletions src/api/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
12 changes: 9 additions & 3 deletions src/common/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface BaseErrorParams<T extends string> {
id: number;
message: string;
httpStatusCode: number;
internalLog?: string;
}

export abstract class BaseError<T extends string> extends Error {
Expand All @@ -14,20 +15,24 @@ export abstract class BaseError<T extends string> extends Error {

public httpStatusCode: number;

constructor({ name, id, message, httpStatusCode }: BaseErrorParams<T>) {
public internalLog: string | undefined;

constructor({ name, id, message, httpStatusCode, internalLog }: BaseErrorParams<T>) {
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,
Expand Down Expand Up @@ -67,14 +72,15 @@ 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,
message:
message ||
"An internal server error occurred. Please try again or contact support.",
httpStatusCode: 500,
internalLog
});
}
}
Expand Down
2 changes: 2 additions & 0 deletions tests/live/clearSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down
Loading