Skip to content

Commit a2a795c

Browse files
committed
Add payment method info to the final payment notification email
1 parent 68d77b4 commit a2a795c

File tree

3 files changed

+168
-4
lines changed

3 files changed

+168
-4
lines changed

src/api/functions/stripe.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { InternalServerError } from "common/errors/index.js";
1+
import { InternalServerError, ValidationError } from "common/errors/index.js";
2+
import { capitalizeFirstLetter } from "common/types/roomRequest.js";
23
import Stripe from "stripe";
34

45
export type StripeLinkCreateParams = {
@@ -128,3 +129,115 @@ export const deactivateStripeProduct = async ({
128129
active: false,
129130
});
130131
};
132+
133+
export const getStripePaymentIntentData = async ({
134+
stripeClient,
135+
paymentIntentId,
136+
stripeApiKey,
137+
}: {
138+
paymentIntentId: string;
139+
stripeApiKey: string;
140+
stripeClient?: Stripe;
141+
}) => {
142+
const stripe = stripeClient || new Stripe(stripeApiKey);
143+
return await stripe.paymentIntents.retrieve(paymentIntentId);
144+
};
145+
146+
export const getPaymentMethodForPaymentIntent = async ({
147+
paymentIntentId,
148+
stripeApiKey,
149+
}: {
150+
paymentIntentId: string;
151+
stripeApiKey: string;
152+
}) => {
153+
const stripe = new Stripe(stripeApiKey);
154+
const paymentIntentData = await getStripePaymentIntentData({
155+
paymentIntentId,
156+
stripeApiKey,
157+
stripeClient: stripe,
158+
});
159+
if (!paymentIntentData) {
160+
throw new InternalServerError({
161+
internalLog: `Could not find payment intent data for payment intent ID "${paymentIntentId}".`,
162+
});
163+
}
164+
const paymentMethodId = paymentIntentData.payment_method?.toString();
165+
if (!paymentMethodId) {
166+
throw new InternalServerError({
167+
internalLog: `Could not find payment method ID for payment intent ID "${paymentIntentId}".`,
168+
});
169+
}
170+
const paymentMethodData =
171+
await stripe.paymentMethods.retrieve(paymentMethodId);
172+
if (!paymentMethodData) {
173+
throw new InternalServerError({
174+
internalLog: `Could not find payment method data for payment intent ID "${paymentIntentId}".`,
175+
});
176+
}
177+
return paymentMethodData;
178+
};
179+
180+
export const supportedStripePaymentMethods = [
181+
"us_bank_account",
182+
"card",
183+
"card_present",
184+
] as const;
185+
export type SupportedStripePaymentMethod =
186+
(typeof supportedStripePaymentMethods)[number];
187+
export const paymentMethodTypeToFriendlyName: Record<
188+
SupportedStripePaymentMethod,
189+
string
190+
> = {
191+
us_bank_account: "ACH Direct Debit",
192+
card: "Credit/Debit Card",
193+
card_present: "Credit/Debit Card (Card Present)",
194+
};
195+
196+
export const cardBrandMap: Record<string, string> = {
197+
amex: "American Express",
198+
american_express: "American Express",
199+
cartes_bancaires: "Cartes Bancaires",
200+
diners: "Diners Club",
201+
diners_club: "Diners Club",
202+
discover: "Discover",
203+
eftpos_au: "EFTPOS Australia",
204+
eftpos_australia: "EFTPOS Australia",
205+
interac: "Interac",
206+
jcb: "JCB",
207+
link: "Link",
208+
mastercard: "Mastercard",
209+
unionpay: "UnionPay",
210+
visa: "Visa",
211+
unknown: "Unknown Brand",
212+
other: "Unknown Brand",
213+
};
214+
215+
export const getPaymentMethodDescriptionString = ({
216+
paymentMethod,
217+
paymentMethodType,
218+
}: {
219+
paymentMethod: Stripe.PaymentMethod;
220+
paymentMethodType: SupportedStripePaymentMethod;
221+
}) => {
222+
const friendlyName = paymentMethodTypeToFriendlyName[paymentMethodType];
223+
switch (paymentMethodType) {
224+
case "us_bank_account":
225+
const bankData = paymentMethod[paymentMethodType];
226+
if (!bankData) {
227+
return null;
228+
}
229+
return `${friendlyName} (${bankData.bank_name} ${capitalizeFirstLetter(bankData.account_type!)} ${bankData.last4})`;
230+
case "card":
231+
const cardData = paymentMethod[paymentMethodType];
232+
if (!cardData) {
233+
return null;
234+
}
235+
return `${friendlyName} (${cardBrandMap[cardData.display_brand || "unknown"]} ending in ${cardData.last4}`;
236+
case "card_present":
237+
const cardPresentData = paymentMethod[paymentMethodType];
238+
if (!cardPresentData) {
239+
return null;
240+
}
241+
return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4}`;
242+
}
243+
};

src/api/routes/stripe.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import {
1414
createStripeLink,
1515
deactivateStripeLink,
1616
deactivateStripeProduct,
17+
getPaymentMethodDescriptionString,
18+
getPaymentMethodForPaymentIntent,
19+
paymentMethodTypeToFriendlyName,
1720
StripeLinkCreateParams,
21+
SupportedStripePaymentMethod,
22+
supportedStripePaymentMethods,
1823
} from "api/functions/stripe.js";
1924
import { getSecretValue } from "api/plugins/auth.js";
2025
import {
@@ -457,6 +462,43 @@ Please ask the payee to try again, perhaps with a different payment method, or c
457462
const eventId = event.id;
458463
const paymentAmount = event.data.object.amount_total;
459464
const paymentCurrency = event.data.object.currency;
465+
const paymentIntentId =
466+
event.data.object.payment_intent?.toString();
467+
if (!paymentIntentId) {
468+
request.log.warn(
469+
"Could not find payment intent ID in webhook payload!",
470+
);
471+
throw new ValidationError({
472+
message: "No payment intent ID found.",
473+
});
474+
}
475+
const stripeApiKey = fastify.secretConfig.stripe_secret_key;
476+
const paymentMethodData = await getPaymentMethodForPaymentIntent({
477+
paymentIntentId,
478+
stripeApiKey,
479+
});
480+
const paymentMethodType =
481+
paymentMethodData.type.toString() as SupportedStripePaymentMethod;
482+
if (
483+
!supportedStripePaymentMethods.includes(
484+
paymentMethodData.type.toString() as SupportedStripePaymentMethod,
485+
)
486+
) {
487+
throw new InternalServerError({
488+
internalLog: `Unknown payment method type ${paymentMethodData.type}!`,
489+
});
490+
}
491+
const paymentMethodDescriptionData =
492+
paymentMethodData[paymentMethodType];
493+
if (!paymentMethodDescriptionData) {
494+
throw new InternalServerError({
495+
internalLog: `Unknown payment method type ${paymentMethodData.type}!`,
496+
});
497+
}
498+
const paymentMethodString = getPaymentMethodDescriptionString({
499+
paymentMethod: paymentMethodData,
500+
paymentMethodType,
501+
});
460502
const { email, name } = event.data.object.customer_details || {
461503
email: null,
462504
name: null,
@@ -581,6 +623,9 @@ Please contact Officer Board with any questions.
581623
subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`,
582624
content: `
583625
ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}).
626+
627+
${paymentMethodString ? `\nPayment method: ${paymentMethodString}.\n` : ""}
628+
584629
${paidInFull ? "\nThis invoice should now be considered settled.\n" : ""}
585630
Please contact Officer Board with any questions.`,
586631
callToActionButton: {

src/common/errors/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ interface BaseErrorParams<T extends string> {
33
id: number;
44
message: string;
55
httpStatusCode: number;
6+
internalLog?: string;
67
}
78

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

1516
public httpStatusCode: number;
1617

17-
constructor({ name, id, message, httpStatusCode }: BaseErrorParams<T>) {
18+
public internalLog: string | undefined;
19+
20+
constructor({ name, id, message, httpStatusCode, internalLog }: BaseErrorParams<T>) {
1821
super(message || name || "Error");
1922
this.name = name;
2023
this.id = id;
2124
this.message = message;
2225
this.httpStatusCode = httpStatusCode;
26+
this.internalLog = internalLog;
2327
if (Error.captureStackTrace) {
2428
Error.captureStackTrace(this, this.constructor);
2529
}
2630
}
2731

2832
toString() {
29-
return `Error ${this.id} (${this.name}): ${this.message}\n\n${this.stack}`;
33+
return `Error ${this.id} (${this.name}): ${this.message}${this.internalLog ? `\n\nInternal Message: ${this.internalLog}` : ''}\n\n${this.stack}`;
3034
}
35+
3136
toJson() {
3237
return {
3338
error: true,
@@ -67,14 +72,15 @@ export class UnauthenticatedError extends BaseError<"UnauthenticatedError"> {
6772
}
6873

6974
export class InternalServerError extends BaseError<"InternalServerError"> {
70-
constructor({ message }: { message?: string } = {}) {
75+
constructor({ message, internalLog }: { message?: string, internalLog?: string } = {}) {
7176
super({
7277
name: "InternalServerError",
7378
id: 100,
7479
message:
7580
message ||
7681
"An internal server error occurred. Please try again or contact support.",
7782
httpStatusCode: 500,
83+
internalLog
7884
});
7985
}
8086
}

0 commit comments

Comments
 (0)