Skip to content

Commit 2d1fac4

Browse files
committed
Create stripe customer IDs for users when syncing identity
1 parent 40eb9eb commit 2d1fac4

File tree

5 files changed

+191
-35
lines changed

5 files changed

+191
-35
lines changed

src/api/functions/stripe.ts

Lines changed: 76 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,28 @@ 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+
};
305+
306+
export const createStripeCustomer = async ({
307+
email,
308+
name,
309+
stripeApiKey,
310+
metadata,
311+
}: StripeCustomerCreateParams): Promise<string> => {
312+
const stripe = new Stripe(stripeApiKey);
313+
const customer = await stripe.customers.create({
314+
email,
315+
name,
316+
metadata: {
317+
...metadata,
318+
...(isProd ? {} : { environment: process.env.RunEnvironment }),
319+
},
320+
});
321+
return customer.id;
322+
};

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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
109109
lastName: surname,
110110
netId,
111111
dynamoClient: fastify.dynamoClient,
112+
redisClient: fastify.redisClient,
113+
stripeApiKey: fastify.secretConfig.stripe_secret_key,
114+
logger: request.log,
112115
});
113116
let isPaidMember = await checkPaidMembershipFromRedis(
114117
netId,
@@ -123,7 +126,7 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
123126
}
124127
if (isPaidMember) {
125128
const username = `${netId}@illinois.edu`;
126-
request.log.info("User is paid member, syncing profile!");
129+
request.log.info("User is paid member, syncing Entra user!");
127130
const entraIdToken = await getEntraIdToken({
128131
clients: await getAuthorizedClients(),
129132
clientId: fastify.environmentConfig.AadValidClientId,

src/api/routes/v2/membership.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
checkPaidMembershipFromRedis,
44
checkExternalMembership,
55
MEMBER_CACHE_SECONDS,
6-
setPaidMembershipInTable,
76
} from "api/functions/membership.js";
87
import { FastifyPluginAsync } from "fastify";
98
import {
@@ -12,7 +11,10 @@ import {
1211
ValidationError,
1312
} from "common/errors/index.js";
1413
import rateLimiter from "api/plugins/rateLimiter.js";
15-
import { createCheckoutSession } from "api/functions/stripe.js";
14+
import {
15+
createCheckoutSession,
16+
createCheckoutSessionWithCustomer,
17+
} from "api/functions/stripe.js";
1618
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1719
import * as z from "zod/v4";
1820
import {
@@ -125,6 +127,9 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
125127
lastName: surname,
126128
netId,
127129
dynamoClient: fastify.dynamoClient,
130+
redisClient: fastify.redisClient,
131+
stripeApiKey: fastify.secretConfig.stripe_secret_key,
132+
logger: request.log,
128133
});
129134
let isPaidMember = await checkPaidMembershipFromRedis(
130135
netId,
@@ -137,18 +142,24 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
137142
fastify.dynamoClient,
138143
);
139144
}
140-
await savePromise;
141-
request.log.debug("Saved user hashed UIN!");
142-
if (isPaidMember) {
143-
throw new ValidationError({
144-
message: `${upn} is already a paid member.`,
145-
});
145+
const userData = await savePromise;
146+
if (!userData) {
147+
request.log.error(
148+
"Was expecting to get a user data save, but we didn't!",
149+
);
150+
throw new InternalServerError({});
146151
}
152+
request.log.debug("Saved user hashed UIN!");
153+
// if (isPaidMember) {
154+
// throw new ValidationError({
155+
// message: `${upn} is already a paid member.`,
156+
// });
157+
// }
147158
return reply.status(200).send(
148-
await createCheckoutSession({
159+
await createCheckoutSessionWithCustomer({
149160
successUrl: "https://acm.illinois.edu/paid",
150161
returnUrl: "https://acm.illinois.edu/membership",
151-
customerEmail: upn,
162+
customerId: userData.stripeCustomerId,
152163
stripeApiKey: fastify.secretConfig.stripe_secret_key as string,
153164
items: [
154165
{

src/api/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ export async function retryDynamoTransactionWithBackoff<T>(
4242

4343
throw lastError;
4444
}
45+
46+
export const isProd = process.env.RunEnvironment === "prod";

0 commit comments

Comments
 (0)