Skip to content

Commit cfe9bb8

Browse files
Create stripe customer IDs for users when syncing identity (#348)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added customer profile integration with Stripe checkout for improved payment session management. * **Bug Fixes** * Enhanced profile synchronization to reliably maintain customer data during checkout operations. * **Improvements** * Refined checkout flow to leverage user customer profiles for more consistent transaction handling. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 40eb9eb commit cfe9bb8

File tree

6 files changed

+306
-35
lines changed

6 files changed

+306
-35
lines changed

src/api/functions/identity.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
2+
import { unmarshall } from "@aws-sdk/util-dynamodb";
3+
import { ValidLoggers } from "api/types.js";
4+
import { genericConfig } from "common/config.js";
5+
6+
export interface UserIdentity {
7+
id: string;
8+
uinHash?: string;
9+
netId?: string;
10+
firstName?: string;
11+
lastName?: string;
12+
stripeCustomerId?: string;
13+
updatedAt?: string;
14+
}
15+
16+
export interface GetUserIdentityInputs {
17+
netId: string;
18+
dynamoClient: DynamoDBClient;
19+
logger: ValidLoggers;
20+
}
21+
22+
export async function getUserIdentity({
23+
netId,
24+
dynamoClient,
25+
logger,
26+
}: GetUserIdentityInputs): Promise<UserIdentity | null> {
27+
const userId = `${netId}@illinois.edu`;
28+
29+
try {
30+
const result = await dynamoClient.send(
31+
new GetItemCommand({
32+
TableName: genericConfig.UserInfoTable,
33+
Key: {
34+
id: { S: userId },
35+
},
36+
ConsistentRead: true,
37+
}),
38+
);
39+
40+
if (!result.Item) {
41+
logger.info(`No user found for netId: ${netId}`);
42+
return null;
43+
}
44+
return unmarshall(result.Item) as UserIdentity;
45+
} catch (error) {
46+
logger.error(`Error fetching user identity for ${netId}: ${error}`);
47+
throw error;
48+
}
49+
}

src/api/functions/stripe.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isProd } from "api/utils.js";
12
import { InternalServerError, ValidationError } from "common/errors/index.js";
23
import { capitalizeFirstLetter } from "common/types/roomRequest.js";
34
import Stripe from "stripe";
@@ -23,6 +24,18 @@ export type StripeCheckoutSessionCreateParams = {
2324
customFields?: Stripe.Checkout.SessionCreateParams.CustomField[];
2425
};
2526

27+
export type StripeCheckoutSessionCreateWithCustomerParams = {
28+
successUrl?: string;
29+
returnUrl?: string;
30+
customerId: string;
31+
stripeApiKey: string;
32+
items: { price: string; quantity: number }[];
33+
initiator: string;
34+
metadata?: Record<string, string>;
35+
allowPromotionCodes: boolean;
36+
customFields?: Stripe.Checkout.SessionCreateParams.CustomField[];
37+
};
38+
2639
/**
2740
* Create a Stripe payment link for an invoice. Note that invoiceAmountUsd MUST IN CENTS!!
2841
* @param {StripeLinkCreateParams} options
@@ -107,6 +120,44 @@ export const createCheckoutSession = async ({
107120
return session.url;
108121
};
109122

123+
export const createCheckoutSessionWithCustomer = async ({
124+
successUrl,
125+
returnUrl,
126+
stripeApiKey,
127+
customerId,
128+
items,
129+
initiator,
130+
allowPromotionCodes,
131+
customFields,
132+
metadata,
133+
}: StripeCheckoutSessionCreateWithCustomerParams): Promise<string> => {
134+
const stripe = new Stripe(stripeApiKey);
135+
const payload: Stripe.Checkout.SessionCreateParams = {
136+
success_url: successUrl || "",
137+
cancel_url: returnUrl || "",
138+
payment_method_types: ["card"],
139+
line_items: items.map((item) => ({
140+
price: item.price,
141+
quantity: item.quantity,
142+
})),
143+
mode: "payment",
144+
customer: customerId,
145+
metadata: {
146+
...(metadata || {}),
147+
initiator,
148+
},
149+
allow_promotion_codes: allowPromotionCodes,
150+
custom_fields: customFields,
151+
};
152+
const session = await stripe.checkout.sessions.create(payload);
153+
if (!session.url) {
154+
throw new InternalServerError({
155+
message: "Could not create Stripe checkout session.",
156+
});
157+
}
158+
return session.url;
159+
};
160+
110161
export const deactivateStripeLink = async ({
111162
linkId,
112163
stripeApiKey,
@@ -244,3 +295,33 @@ export const getPaymentMethodDescriptionString = ({
244295
return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4})`;
245296
}
246297
};
298+
299+
export type StripeCustomerCreateParams = {
300+
email: string;
301+
name: string;
302+
stripeApiKey: string;
303+
metadata?: Record<string, string>;
304+
idempotencyKey?: string;
305+
};
306+
307+
export const createStripeCustomer = async ({
308+
email,
309+
name,
310+
stripeApiKey,
311+
metadata,
312+
idempotencyKey,
313+
}: StripeCustomerCreateParams): Promise<string> => {
314+
const stripe = new Stripe(stripeApiKey, { maxNetworkRetries: 2 });
315+
const customer = await stripe.customers.create(
316+
{
317+
email,
318+
name,
319+
metadata: {
320+
...(metadata ?? {}),
321+
...(isProd ? {} : { environment: process.env.RunEnvironment }),
322+
},
323+
},
324+
idempotencyKey ? { idempotencyKey } : undefined,
325+
);
326+
return customer.id;
327+
};

src/api/functions/sync.ts

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,22 @@ import {
22
UpdateItemCommand,
33
type DynamoDBClient,
44
} from "@aws-sdk/client-dynamodb";
5+
import { Redis, ValidLoggers } from "api/types.js";
56
import { genericConfig } from "common/config.js";
7+
import { createLock, IoredisAdapter } from "redlock-universal";
8+
import { createStripeCustomer } from "./stripe.js";
9+
import { InternalServerError } from "common/errors/index.js";
10+
import { unmarshall } from "@aws-sdk/util-dynamodb";
611

712
export interface SyncFullProfileInputs {
813
uinHash: string;
914
netId: string;
1015
firstName: string;
1116
lastName: string;
1217
dynamoClient: DynamoDBClient;
18+
redisClient: Redis;
19+
stripeApiKey: string;
20+
logger: ValidLoggers;
1321
}
1422

1523
export async function syncFullProfile({
@@ -18,29 +26,85 @@ export async function syncFullProfile({
1826
firstName,
1927
lastName,
2028
dynamoClient,
29+
redisClient,
30+
stripeApiKey,
31+
logger,
2132
}: SyncFullProfileInputs) {
22-
return dynamoClient.send(
23-
new UpdateItemCommand({
24-
TableName: genericConfig.UserInfoTable,
25-
Key: {
26-
id: { S: `${netId}@illinois.edu` },
27-
},
28-
UpdateExpression:
29-
"SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName",
30-
ExpressionAttributeNames: {
31-
"#uinHash": "uinHash",
32-
"#netId": "netId",
33-
"#updatedAt": "updatedAt",
34-
"#firstName": "firstName",
35-
"#lastName": "lastName",
36-
},
37-
ExpressionAttributeValues: {
38-
":uinHash": { S: uinHash },
39-
":netId": { S: netId },
40-
":firstName": { S: firstName },
41-
":lastName": { S: lastName },
42-
":updatedAt": { S: new Date().toISOString() },
43-
},
44-
}),
45-
);
33+
const lock = createLock({
34+
adapter: new IoredisAdapter(redisClient),
35+
key: `userSync:${netId}`,
36+
retryAttempts: 5,
37+
retryDelay: 300,
38+
});
39+
40+
return await lock.using(async (signal) => {
41+
const userId = `${netId}@illinois.edu`;
42+
const updateResult = await dynamoClient.send(
43+
new UpdateItemCommand({
44+
TableName: genericConfig.UserInfoTable,
45+
Key: {
46+
id: { S: userId },
47+
},
48+
UpdateExpression:
49+
"SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName",
50+
ExpressionAttributeNames: {
51+
"#uinHash": "uinHash",
52+
"#netId": "netId",
53+
"#updatedAt": "updatedAt",
54+
"#firstName": "firstName",
55+
"#lastName": "lastName",
56+
},
57+
ExpressionAttributeValues: {
58+
":uinHash": { S: uinHash },
59+
":netId": { S: netId },
60+
":firstName": { S: firstName },
61+
":lastName": { S: lastName },
62+
":updatedAt": { S: new Date().toISOString() },
63+
},
64+
ReturnValues: "ALL_NEW",
65+
}),
66+
);
67+
68+
const stripeCustomerId = updateResult.Attributes?.stripeCustomerId?.S;
69+
70+
if (!stripeCustomerId) {
71+
if (signal.aborted) {
72+
throw new InternalServerError({
73+
message:
74+
"Checked on lock before creating Stripe customer, we've lost the lock!",
75+
});
76+
}
77+
const newStripeCustomerId = await createStripeCustomer({
78+
email: userId,
79+
name: `${firstName} ${lastName}`,
80+
stripeApiKey,
81+
});
82+
logger.info(`Created new Stripe customer for ${userId}.`);
83+
const newInfo = await dynamoClient.send(
84+
new UpdateItemCommand({
85+
TableName: genericConfig.UserInfoTable,
86+
Key: {
87+
id: { S: userId },
88+
},
89+
UpdateExpression: "SET #stripeCustomerId = :stripeCustomerId",
90+
ExpressionAttributeNames: {
91+
"#stripeCustomerId": "stripeCustomerId",
92+
},
93+
ExpressionAttributeValues: {
94+
":stripeCustomerId": { S: newStripeCustomerId },
95+
},
96+
ReturnValues: "ALL_NEW",
97+
}),
98+
);
99+
return newInfo && newInfo.Attributes
100+
? unmarshall(newInfo.Attributes)
101+
: updateResult && updateResult.Attributes
102+
? unmarshall(updateResult.Attributes)
103+
: undefined;
104+
}
105+
106+
return updateResult && updateResult.Attributes
107+
? unmarshall(updateResult.Attributes)
108+
: undefined;
109+
});
46110
}

src/api/routes/syncIdentity.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolveEmailToOid,
2020
} from "api/functions/entraId.js";
2121
import { syncFullProfile } from "api/functions/sync.js";
22+
import { getUserIdentity, UserIdentity } from "api/functions/identity.js";
2223

2324
const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
2425
const getAuthorizedClients = async () => {
@@ -109,6 +110,9 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
109110
lastName: surname,
110111
netId,
111112
dynamoClient: fastify.dynamoClient,
113+
redisClient: fastify.redisClient,
114+
stripeApiKey: fastify.secretConfig.stripe_secret_key,
115+
logger: request.log,
112116
});
113117
let isPaidMember = await checkPaidMembershipFromRedis(
114118
netId,
@@ -123,7 +127,7 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
123127
}
124128
if (isPaidMember) {
125129
const username = `${netId}@illinois.edu`;
126-
request.log.info("User is paid member, syncing profile!");
130+
request.log.info("User is paid member, syncing Entra user!");
127131
const entraIdToken = await getEntraIdToken({
128132
clients: await getAuthorizedClients(),
129133
clientId: fastify.environmentConfig.AadValidClientId,
@@ -141,6 +145,66 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
141145
return reply.status(201).send();
142146
},
143147
);
148+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
149+
"/isRequired",
150+
{
151+
schema: withTags(["Generic"], {
152+
headers: z.object({
153+
"x-uiuc-token": z.jwt().min(1).meta({
154+
description:
155+
"An access token for the user in the UIUC Entra ID tenant.",
156+
}),
157+
}),
158+
summary: "Check if a user needs a full user identity sync.",
159+
response: {
160+
200: {
161+
description: "The status was retrieved.",
162+
content: {
163+
"application/json": {
164+
schema: z.object({
165+
syncRequired: z.boolean().default(false),
166+
}),
167+
},
168+
},
169+
},
170+
403: notAuthenticatedError,
171+
},
172+
}),
173+
},
174+
async (request, reply) => {
175+
const accessToken = request.headers["x-uiuc-token"];
176+
const verifiedData = await verifyUiucAccessToken({
177+
accessToken,
178+
logger: request.log,
179+
});
180+
const { userPrincipalName: upn, givenName, surname } = verifiedData;
181+
const netId = upn.replace("@illinois.edu", "");
182+
if (netId.includes("@")) {
183+
request.log.error(
184+
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
185+
);
186+
throw new ValidationError({
187+
message: "ID token could not be parsed.",
188+
});
189+
}
190+
const userIdentity = await getUserIdentity({
191+
netId,
192+
dynamoClient: fastify.dynamoClient,
193+
logger: request.log,
194+
});
195+
196+
const requiredFields: (keyof UserIdentity)[] = [
197+
"uinHash",
198+
"firstName",
199+
"lastName",
200+
"stripeCustomerId",
201+
];
202+
203+
const syncRequired =
204+
!userIdentity || requiredFields.some((field) => !userIdentity[field]);
205+
return reply.send({ syncRequired });
206+
},
207+
);
144208
};
145209
fastify.register(limitedRoutes);
146210
};

0 commit comments

Comments
 (0)