-
Notifications
You must be signed in to change notification settings - Fork 0
Create stripe customer IDs for users when syncing identity #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<UserIdentity | null> { | ||
| 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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,14 +2,22 @@ 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; | ||||||||||||||||||||||||||||
| netId: string; | ||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Set an explicit lock TTL and reduce work under the lock. Add a clear duration to avoid silent expiry during network calls. Also check for lock loss both before and after external calls. const lock = createLock({
adapter: new IoredisAdapter(redisClient),
key: `userSync:${netId}`,
+ duration: 15000, // 15s; tune based on p95 of the section below
retryAttempts: 5,
retryDelay: 300,
});And add a second abort check just before the final Dynamo write (see diff further below). 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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( | ||||||||||||||||||||||||||||
|
Comment on lines
+70
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent duplicate Stripe customers: use an idempotency key when creating the customer. If the lock expires mid‑flow, concurrent runs can each create a Stripe customer. Protect the Stripe call with a deterministic idempotency key tied to the user. - const newStripeCustomerId = await createStripeCustomer({
- email: userId,
- name: `${firstName} ${lastName}`,
- stripeApiKey,
- });
+ const idempotencyKey = `acm-create-stripe-customer:${userId}`;
+ const newStripeCustomerId = await createStripeCustomer({
+ email: userId,
+ name: `${firstName} ${lastName}`,
+ stripeApiKey,
+ metadata: { userId },
+ idempotencyKey,
+ });Companion changes are proposed in src/api/functions/stripe.ts to accept and pass the idempotency key.
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||
|
Comment on lines
+83
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the Ensure only the first writer sets the field; others should not clobber and should read the winning value. - const newInfo = await dynamoClient.send(
- new UpdateItemCommand({
+ if (signal.aborted) {
+ throw new InternalServerError({ message: "Lost lock before persisting Stripe customer ID." });
+ }
+ try {
+ 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 },
},
+ ConditionExpression: "attribute_not_exists(#stripeCustomerId)",
ReturnValues: "ALL_NEW",
- }),
- );
- return newInfo && newInfo.Attributes
- ? unmarshall(newInfo.Attributes)
- : updateResult && updateResult.Attributes
- ? unmarshall(updateResult.Attributes)
- : undefined;
+ }),
+ );
+ return newInfo && newInfo.Attributes
+ ? unmarshall(newInfo.Attributes)
+ : updateResult && updateResult.Attributes
+ ? unmarshall(updateResult.Attributes)
+ : undefined;
+ } catch (err: any) {
+ // Another writer won the race; fetch the latest record.
+ const latest = await dynamoClient.send(
+ new UpdateItemCommand({
+ TableName: genericConfig.UserInfoTable,
+ Key: { id: { S: userId } },
+ // no-op update to get ALL_NEW without needing an extra import
+ UpdateExpression: "SET #updatedAt = :updatedAt",
+ ExpressionAttributeNames: { "#updatedAt": "updatedAt" },
+ ExpressionAttributeValues: { ":updatedAt": { S: new Date().toISOString() } },
+ ReturnValues: "ALL_NEW",
+ }),
+ );
+ return latest && latest.Attributes ? unmarshall(latest.Attributes) : undefined;
+ }Note: If preferred, switch to GetItem for a pure read.
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| ? unmarshall(newInfo.Attributes) | ||||||||||||||||||||||||||||
| : updateResult && updateResult.Attributes | ||||||||||||||||||||||||||||
| ? unmarshall(updateResult.Attributes) | ||||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return updateResult && updateResult.Attributes | ||||||||||||||||||||||||||||
| ? unmarshall(updateResult.Attributes) | ||||||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 () => { | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -109,6 +110,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, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+113
to
116
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Stripe secret before calling downstream. Fail fast with a clear error if await syncFullProfile({
+ ...(fastify.secretConfig.stripe_secret_key
+ ? {}
+ : (() => {
+ request.log.error("Missing Stripe secret key.");
+ throw new InternalServerError({ message: "Stripe is not configured." });
+ })()),
uinHash,
firstName: givenName,
lastName: surname,
netId,
dynamoClient: fastify.dynamoClient,
redisClient: fastify.redisClient,
stripeApiKey: fastify.secretConfig.stripe_secret_key,
logger: request.log,
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| let isPaidMember = await checkPaidMembershipFromRedis( | ||||||||||||||||||||||||||||||||||||||||||
| netId, | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -123,7 +127,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, | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -141,6 +145,66 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => { | |||||||||||||||||||||||||||||||||||||||||
| return reply.status(201).send(); | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().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); | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Add a runtime guard for missing
customerId.Defensive check to avoid silent bad requests to Stripe.
export const createCheckoutSessionWithCustomer = async ({ successUrl, returnUrl, stripeApiKey, customerId, items, initiator, allowPromotionCodes, customFields, metadata, }: StripeCheckoutSessionCreateWithCustomerParams): Promise<string> => { + if (!customerId) { + throw new ValidationError({ message: "Missing Stripe customerId." }); + } const stripe = new Stripe(stripeApiKey);📝 Committable suggestion
🤖 Prompt for AI Agents