From 2d1fac46262937780efdd28ecd3b0c11ec50fa30 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 26 Oct 2025 22:11:39 -0500 Subject: [PATCH 1/4] Create stripe customer IDs for users when syncing identity --- src/api/functions/stripe.ts | 76 ++++++++++++++++++++++ src/api/functions/sync.ts | 112 +++++++++++++++++++++++++------- src/api/routes/syncIdentity.ts | 5 +- src/api/routes/v2/membership.ts | 31 ++++++--- src/api/utils.ts | 2 + 5 files changed, 191 insertions(+), 35 deletions(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index d74f0676..09b29d19 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -1,3 +1,4 @@ +import { isProd } from "api/utils.js"; import { InternalServerError, ValidationError } from "common/errors/index.js"; import { capitalizeFirstLetter } from "common/types/roomRequest.js"; import Stripe from "stripe"; @@ -23,6 +24,18 @@ export type StripeCheckoutSessionCreateParams = { customFields?: Stripe.Checkout.SessionCreateParams.CustomField[]; }; +export type StripeCheckoutSessionCreateWithCustomerParams = { + successUrl?: string; + returnUrl?: string; + customerId?: string; + stripeApiKey: string; + items: { price: string; quantity: number }[]; + initiator: string; + metadata?: Record; + allowPromotionCodes: boolean; + customFields?: Stripe.Checkout.SessionCreateParams.CustomField[]; +}; + /** * Create a Stripe payment link for an invoice. Note that invoiceAmountUsd MUST IN CENTS!! * @param {StripeLinkCreateParams} options @@ -107,6 +120,44 @@ export const createCheckoutSession = async ({ return session.url; }; +export const createCheckoutSessionWithCustomer = async ({ + successUrl, + returnUrl, + stripeApiKey, + customerId, + items, + initiator, + allowPromotionCodes, + customFields, + metadata, +}: StripeCheckoutSessionCreateWithCustomerParams): Promise => { + const stripe = new Stripe(stripeApiKey); + const payload: Stripe.Checkout.SessionCreateParams = { + success_url: successUrl || "", + cancel_url: returnUrl || "", + payment_method_types: ["card"], + line_items: items.map((item) => ({ + price: item.price, + quantity: item.quantity, + })), + mode: "payment", + customer: customerId, + metadata: { + ...(metadata || {}), + initiator, + }, + allow_promotion_codes: allowPromotionCodes, + custom_fields: customFields, + }; + const session = await stripe.checkout.sessions.create(payload); + if (!session.url) { + throw new InternalServerError({ + message: "Could not create Stripe checkout session.", + }); + } + return session.url; +}; + export const deactivateStripeLink = async ({ linkId, stripeApiKey, @@ -244,3 +295,28 @@ export const getPaymentMethodDescriptionString = ({ return `${friendlyName} (${cardBrandMap[cardPresentData.brand || "unknown"]} ending in ${cardPresentData.last4})`; } }; + +export type StripeCustomerCreateParams = { + email: string; + name: string; + stripeApiKey: string; + metadata?: Record; +}; + +export const createStripeCustomer = async ({ + email, + name, + stripeApiKey, + metadata, +}: StripeCustomerCreateParams): Promise => { + const stripe = new Stripe(stripeApiKey); + const customer = await stripe.customers.create({ + email, + name, + metadata: { + ...metadata, + ...(isProd ? {} : { environment: process.env.RunEnvironment }), + }, + }); + return customer.id; +}; diff --git a/src/api/functions/sync.ts b/src/api/functions/sync.ts index 190b782f..052197f4 100644 --- a/src/api/functions/sync.ts +++ b/src/api/functions/sync.ts @@ -2,7 +2,12 @@ import { UpdateItemCommand, type DynamoDBClient, } from "@aws-sdk/client-dynamodb"; +import { Redis, ValidLoggers } from "api/types.js"; import { genericConfig } from "common/config.js"; +import { createLock, IoredisAdapter } from "redlock-universal"; +import { createStripeCustomer } from "./stripe.js"; +import { InternalServerError } from "common/errors/index.js"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; export interface SyncFullProfileInputs { uinHash: string; @@ -10,6 +15,9 @@ export interface SyncFullProfileInputs { firstName: string; lastName: string; dynamoClient: DynamoDBClient; + redisClient: Redis; + stripeApiKey: string; + logger: ValidLoggers; } export async function syncFullProfile({ @@ -18,29 +26,85 @@ export async function syncFullProfile({ firstName, lastName, dynamoClient, + redisClient, + stripeApiKey, + logger, }: SyncFullProfileInputs) { - return dynamoClient.send( - new UpdateItemCommand({ - TableName: genericConfig.UserInfoTable, - Key: { - id: { S: `${netId}@illinois.edu` }, - }, - UpdateExpression: - "SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName", - ExpressionAttributeNames: { - "#uinHash": "uinHash", - "#netId": "netId", - "#updatedAt": "updatedAt", - "#firstName": "firstName", - "#lastName": "lastName", - }, - ExpressionAttributeValues: { - ":uinHash": { S: uinHash }, - ":netId": { S: netId }, - ":firstName": { S: firstName }, - ":lastName": { S: lastName }, - ":updatedAt": { S: new Date().toISOString() }, - }, - }), - ); + const lock = createLock({ + adapter: new IoredisAdapter(redisClient), + key: `userSync:${netId}`, + retryAttempts: 5, + retryDelay: 300, + }); + + return await lock.using(async (signal) => { + const userId = `${netId}@illinois.edu`; + const updateResult = await dynamoClient.send( + new UpdateItemCommand({ + TableName: genericConfig.UserInfoTable, + Key: { + id: { S: userId }, + }, + UpdateExpression: + "SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName", + ExpressionAttributeNames: { + "#uinHash": "uinHash", + "#netId": "netId", + "#updatedAt": "updatedAt", + "#firstName": "firstName", + "#lastName": "lastName", + }, + ExpressionAttributeValues: { + ":uinHash": { S: uinHash }, + ":netId": { S: netId }, + ":firstName": { S: firstName }, + ":lastName": { S: lastName }, + ":updatedAt": { S: new Date().toISOString() }, + }, + ReturnValues: "ALL_NEW", + }), + ); + + const stripeCustomerId = updateResult.Attributes?.stripeCustomerId?.S; + + if (!stripeCustomerId) { + if (signal.aborted) { + throw new InternalServerError({ + message: + "Checked on lock before creating Stripe customer, we've lost the lock!", + }); + } + const newStripeCustomerId = await createStripeCustomer({ + email: userId, + name: `${firstName} ${lastName}`, + stripeApiKey, + }); + logger.info(`Created new Stripe customer for ${userId}.`); + const newInfo = await dynamoClient.send( + new UpdateItemCommand({ + TableName: genericConfig.UserInfoTable, + Key: { + id: { S: userId }, + }, + UpdateExpression: "SET #stripeCustomerId = :stripeCustomerId", + ExpressionAttributeNames: { + "#stripeCustomerId": "stripeCustomerId", + }, + ExpressionAttributeValues: { + ":stripeCustomerId": { S: newStripeCustomerId }, + }, + ReturnValues: "ALL_NEW", + }), + ); + return newInfo && newInfo.Attributes + ? unmarshall(newInfo.Attributes) + : updateResult && updateResult.Attributes + ? unmarshall(updateResult.Attributes) + : undefined; + } + + return updateResult && updateResult.Attributes + ? unmarshall(updateResult.Attributes) + : undefined; + }); } diff --git a/src/api/routes/syncIdentity.ts b/src/api/routes/syncIdentity.ts index 07ce9b48..9d9949c8 100644 --- a/src/api/routes/syncIdentity.ts +++ b/src/api/routes/syncIdentity.ts @@ -109,6 +109,9 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { lastName: surname, netId, dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, + stripeApiKey: fastify.secretConfig.stripe_secret_key, + logger: request.log, }); let isPaidMember = await checkPaidMembershipFromRedis( netId, @@ -123,7 +126,7 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { } if (isPaidMember) { const username = `${netId}@illinois.edu`; - request.log.info("User is paid member, syncing profile!"); + request.log.info("User is paid member, syncing Entra user!"); const entraIdToken = await getEntraIdToken({ clients: await getAuthorizedClients(), clientId: fastify.environmentConfig.AadValidClientId, diff --git a/src/api/routes/v2/membership.ts b/src/api/routes/v2/membership.ts index dbce0cc0..3075e5e3 100644 --- a/src/api/routes/v2/membership.ts +++ b/src/api/routes/v2/membership.ts @@ -3,7 +3,6 @@ import { checkPaidMembershipFromRedis, checkExternalMembership, MEMBER_CACHE_SECONDS, - setPaidMembershipInTable, } from "api/functions/membership.js"; import { FastifyPluginAsync } from "fastify"; import { @@ -12,7 +11,10 @@ import { ValidationError, } from "common/errors/index.js"; import rateLimiter from "api/plugins/rateLimiter.js"; -import { createCheckoutSession } from "api/functions/stripe.js"; +import { + createCheckoutSession, + createCheckoutSessionWithCustomer, +} from "api/functions/stripe.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import * as z from "zod/v4"; import { @@ -125,6 +127,9 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { lastName: surname, netId, dynamoClient: fastify.dynamoClient, + redisClient: fastify.redisClient, + stripeApiKey: fastify.secretConfig.stripe_secret_key, + logger: request.log, }); let isPaidMember = await checkPaidMembershipFromRedis( netId, @@ -137,18 +142,24 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => { fastify.dynamoClient, ); } - await savePromise; - request.log.debug("Saved user hashed UIN!"); - if (isPaidMember) { - throw new ValidationError({ - message: `${upn} is already a paid member.`, - }); + const userData = await savePromise; + if (!userData) { + request.log.error( + "Was expecting to get a user data save, but we didn't!", + ); + throw new InternalServerError({}); } + request.log.debug("Saved user hashed UIN!"); + // if (isPaidMember) { + // throw new ValidationError({ + // message: `${upn} is already a paid member.`, + // }); + // } return reply.status(200).send( - await createCheckoutSession({ + await createCheckoutSessionWithCustomer({ successUrl: "https://acm.illinois.edu/paid", returnUrl: "https://acm.illinois.edu/membership", - customerEmail: upn, + customerId: userData.stripeCustomerId, stripeApiKey: fastify.secretConfig.stripe_secret_key as string, items: [ { diff --git a/src/api/utils.ts b/src/api/utils.ts index f367f0db..b25fe053 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -42,3 +42,5 @@ export async function retryDynamoTransactionWithBackoff( throw lastError; } + +export const isProd = process.env.RunEnvironment === "prod"; From f9a51aa59ac4d8f18c54dff2b2f8bed5eafa0083 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 26 Oct 2025 22:22:28 -0500 Subject: [PATCH 2/4] Add route /api/v1/syncIdentity/isRequired to avoid the expensive sync required --- src/api/functions/identity.ts | 49 +++++++++++++++++++++++++++ src/api/routes/syncIdentity.ts | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 src/api/functions/identity.ts diff --git a/src/api/functions/identity.ts b/src/api/functions/identity.ts new file mode 100644 index 00000000..2a3a5c5f --- /dev/null +++ b/src/api/functions/identity.ts @@ -0,0 +1,49 @@ +import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { ValidLoggers } from "api/types.js"; +import { genericConfig } from "common/config.js"; + +export interface UserIdentity { + id: string; + uinHash?: string; + netId?: string; + firstName?: string; + lastName?: string; + stripeCustomerId?: string; + updatedAt?: string; +} + +export interface GetUserIdentityInputs { + netId: string; + dynamoClient: DynamoDBClient; + logger: ValidLoggers; +} + +export async function getUserIdentity({ + netId, + dynamoClient, + logger, +}: GetUserIdentityInputs): Promise { + const userId = `${netId}@illinois.edu`; + + try { + const result = await dynamoClient.send( + new GetItemCommand({ + TableName: genericConfig.UserInfoTable, + Key: { + id: { S: userId }, + }, + ConsistentRead: true, + }), + ); + + if (!result.Item) { + logger.info(`No user found for netId: ${netId}`); + return null; + } + return unmarshall(result.Item) as UserIdentity; + } catch (error) { + logger.error(`Error fetching user identity for ${netId}: ${error}`); + throw error; + } +} diff --git a/src/api/routes/syncIdentity.ts b/src/api/routes/syncIdentity.ts index 9d9949c8..94da1b79 100644 --- a/src/api/routes/syncIdentity.ts +++ b/src/api/routes/syncIdentity.ts @@ -19,6 +19,7 @@ import { resolveEmailToOid, } from "api/functions/entraId.js"; import { syncFullProfile } from "api/functions/sync.js"; +import { getUserIdentity, UserIdentity } from "api/functions/identity.js"; const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { const getAuthorizedClients = async () => { @@ -144,6 +145,66 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { return reply.status(201).send(); }, ); + fastify.withTypeProvider().get( + "/isRequired", + { + schema: withTags(["Generic"], { + headers: z.object({ + "x-uiuc-token": z.jwt().min(1).meta({ + description: + "An access token for the user in the UIUC Entra ID tenant.", + }), + }), + summary: "Check if a user needs a full user identity sync.", + response: { + 200: { + description: "The status was retrieved.", + content: { + "application/json": { + schema: z.object({ + syncRequired: z.boolean().default(false), + }), + }, + }, + }, + 403: notAuthenticatedError, + }, + }), + }, + async (request, reply) => { + const accessToken = request.headers["x-uiuc-token"]; + const verifiedData = await verifyUiucAccessToken({ + accessToken, + logger: request.log, + }); + const { userPrincipalName: upn, givenName, surname } = verifiedData; + const netId = upn.replace("@illinois.edu", ""); + if (netId.includes("@")) { + request.log.error( + `Found UPN ${upn} which cannot be turned into NetID via simple replacement.`, + ); + throw new ValidationError({ + message: "ID token could not be parsed.", + }); + } + const userIdentity = await getUserIdentity({ + netId, + dynamoClient: fastify.dynamoClient, + logger: request.log, + }); + + const requiredFields: (keyof UserIdentity)[] = [ + "uinHash", + "firstName", + "lastName", + "stripeCustomerId", + ]; + + const syncRequired = + !userIdentity || requiredFields.some((field) => !userIdentity[field]); + return reply.send({ syncRequired }); + }, + ); }; fastify.register(limitedRoutes); }; From 643f89806924b52d3334be49f4d0257d519c25d0 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 26 Oct 2025 22:23:24 -0500 Subject: [PATCH 3/4] Update src/api/functions/stripe.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/api/functions/stripe.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 09b29d19..404ed01f 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -301,6 +301,7 @@ export type StripeCustomerCreateParams = { name: string; stripeApiKey: string; metadata?: Record; + idempotencyKey?: string; }; export const createStripeCustomer = async ({ @@ -308,15 +309,19 @@ export const createStripeCustomer = async ({ name, stripeApiKey, metadata, + idempotencyKey, }: StripeCustomerCreateParams): Promise => { - const stripe = new Stripe(stripeApiKey); - const customer = await stripe.customers.create({ - email, - name, - metadata: { - ...metadata, - ...(isProd ? {} : { environment: process.env.RunEnvironment }), + const stripe = new Stripe(stripeApiKey, { maxNetworkRetries: 2 }); + const customer = await stripe.customers.create( + { + email, + name, + metadata: { + ...(metadata ?? {}), + ...(isProd ? {} : { environment: process.env.RunEnvironment }), + }, }, - }); + idempotencyKey ? { idempotencyKey } : undefined, + ); return customer.id; }; From 0438778b70d2c9c1f8648a02b016912bc4708063 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sun, 26 Oct 2025 22:24:02 -0500 Subject: [PATCH 4/4] Up --- src/api/functions/stripe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 404ed01f..25005085 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -27,7 +27,7 @@ export type StripeCheckoutSessionCreateParams = { export type StripeCheckoutSessionCreateWithCustomerParams = { successUrl?: string; returnUrl?: string; - customerId?: string; + customerId: string; stripeApiKey: string; items: { price: string; quantity: number }[]; initiator: string;