Skip to content

Commit c51cbb5

Browse files
authored
Stripe Links: Add payment method info to the payment success email (#209)
1 parent 68d77b4 commit c51cbb5

File tree

5 files changed

+195
-13
lines changed

5 files changed

+195
-13
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 || "checking")} ${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: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,20 @@ import {
66
} from "@aws-sdk/client-dynamodb";
77
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
88
import { withRoles, withTags } from "api/components/index.js";
9-
import {
10-
buildAuditLogTransactPut,
11-
createAuditLogEntry,
12-
} from "api/functions/auditLog.js";
9+
import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
1310
import {
1411
createStripeLink,
1512
deactivateStripeLink,
1613
deactivateStripeProduct,
14+
getPaymentMethodDescriptionString,
15+
getPaymentMethodForPaymentIntent,
16+
paymentMethodTypeToFriendlyName,
1717
StripeLinkCreateParams,
18+
SupportedStripePaymentMethod,
19+
supportedStripePaymentMethods,
1820
} from "api/functions/stripe.js";
1921
import { getSecretValue } from "api/plugins/auth.js";
20-
import {
21-
environmentConfig,
22-
genericConfig,
23-
notificationRecipients,
24-
} from "common/config.js";
22+
import { genericConfig, notificationRecipients } from "common/config.js";
2523
import {
2624
BaseError,
2725
DatabaseFetchError,
@@ -457,6 +455,43 @@ Please ask the payee to try again, perhaps with a different payment method, or c
457455
const eventId = event.id;
458456
const paymentAmount = event.data.object.amount_total;
459457
const paymentCurrency = event.data.object.currency;
458+
const paymentIntentId =
459+
event.data.object.payment_intent?.toString();
460+
if (!paymentIntentId) {
461+
request.log.warn(
462+
"Could not find payment intent ID in webhook payload!",
463+
);
464+
throw new ValidationError({
465+
message: "No payment intent ID found.",
466+
});
467+
}
468+
const stripeApiKey = fastify.secretConfig.stripe_secret_key;
469+
const paymentMethodData = await getPaymentMethodForPaymentIntent({
470+
paymentIntentId,
471+
stripeApiKey,
472+
});
473+
const paymentMethodType =
474+
paymentMethodData.type.toString() as SupportedStripePaymentMethod;
475+
if (
476+
!supportedStripePaymentMethods.includes(
477+
paymentMethodData.type.toString() as SupportedStripePaymentMethod,
478+
)
479+
) {
480+
throw new InternalServerError({
481+
internalLog: `Unknown payment method type ${paymentMethodData.type}!`,
482+
});
483+
}
484+
const paymentMethodDescriptionData =
485+
paymentMethodData[paymentMethodType];
486+
if (!paymentMethodDescriptionData) {
487+
throw new InternalServerError({
488+
internalLog: `No payment method data for ${paymentMethodData.type}!`,
489+
});
490+
}
491+
const paymentMethodString = getPaymentMethodDescriptionString({
492+
paymentMethod: paymentMethodData,
493+
paymentMethodType,
494+
});
460495
const { email, name } = event.data.object.customer_details || {
461496
email: null,
462497
name: null,
@@ -581,6 +616,9 @@ Please contact Officer Board with any questions.
581616
subject: `Payment Recieved for Invoice ${unmarshalledEntry.invoiceId}`,
582617
content: `
583618
ACM @ UIUC has received ${paidInFull ? "full" : "partial"} payment for Invoice ${unmarshalledEntry.invoiceId} (${withCurrency} paid by ${name}, ${email}).
619+
620+
${paymentMethodString ? `\nPayment method: ${paymentMethodString}.\n` : ""}
621+
584622
${paidInFull ? "\nThis invoice should now be considered settled.\n" : ""}
585623
Please contact Officer Board with any questions.`,
586624
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
}

tests/live/clearSession.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ describe("Session clearing tests", async () => {
2929
});
3030
expect(clearResponse.status).toBe(201);
3131
// token should be revoked
32+
// add a sleep because delay shouldn't fail the pipeline
33+
await new Promise((r) => setTimeout(r, 1000));
3234
const responseFail = await fetch(`${baseEndpoint}/api/v1/protected`, {
3335
method: "GET",
3436
headers: {

tests/unit/webhooks.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@ const paymentLinkMock = {
1919
url: `https://buy.stripe.com/${linkId}`,
2020
};
2121

22+
vi.mock(import("../../src/api/functions/stripe.js"), async (importOriginal) => {
23+
const mod = await importOriginal();
24+
return {
25+
...mod,
26+
getPaymentMethodForPaymentIntent: vi.fn().mockImplementation(async () => {
27+
return {
28+
type: "us_bank_account",
29+
us_bank_account: {
30+
bank_name: "ACM Bank N.A.",
31+
account_type: "checking",
32+
last4: "0123",
33+
},
34+
};
35+
}),
36+
getPaymentMethodDescriptionString: vi.fn().mockImplementation(async () => {
37+
return "Your payment method here.";
38+
}),
39+
};
40+
});
41+
2242
const app = await init();
2343
describe("Test Stripe webhooks", async () => {
2444
test("Stripe Payment Link skips non-existing links", async () => {
@@ -38,6 +58,7 @@ describe("Test Stripe webhooks", async () => {
3858
data: {
3959
object: {
4060
payment_link: linkId,
61+
payment_intent: "pi_123",
4162
amount_total: 10000,
4263
currency: "usd",
4364
customer_details: {
@@ -81,6 +102,7 @@ describe("Test Stripe webhooks", async () => {
81102
data: {
82103
object: {
83104
payment_link: linkId,
105+
payment_intent: "pi_123",
84106
amount_total: 10000,
85107
currency: "usd",
86108
customer_details: {
@@ -136,6 +158,7 @@ describe("Test Stripe webhooks", async () => {
136158
object: {
137159
payment_link: linkId,
138160
amount_total: 10000,
161+
payment_intent: "pi_123",
139162
currency: "usd",
140163
customer_details: {
141164
name: "Test User",

0 commit comments

Comments
 (0)