Skip to content

Commit 3937f50

Browse files
committed
add v2 checkout api
1 parent fcd3d26 commit 3937f50

File tree

7 files changed

+201
-144
lines changed

7 files changed

+201
-144
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ clean:
5959
build: src/ cloudformation/
6060
yarn -D
6161
VITE_BUILD_HASH=$(GIT_HASH) yarn build
62-
cd src/api && npx tsx createSwagger.ts
62+
cd src/api && npx tsx --experimental-loader=./mockLoader.mjs createSwagger.ts
6363
cp -r src/api/resources/ dist/api/resources
6464
rm -rf dist/lambda/sqs
6565
sam build --template-file cloudformation/main.yml --use-container --parallel

src/api/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import clearSessionRoute from "./routes/clearSession.js";
5959
import protectedRoute from "./routes/protected.js";
6060
import eventsPlugin from "./routes/events.js";
6161
import mobileWalletV2Route from "./routes/v2/mobileWallet.js";
62+
import membershipV2Plugin from "./routes/v2/membership.js";
6263
/** END ROUTES */
6364

6465
export const instanceId = randomUUID();
@@ -120,7 +121,7 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
120121
title: "ACM @ UIUC Core API",
121122
description:
122123
"The ACM @ UIUC Core API provides services for managing chapter operations.",
123-
version: "1.1.0",
124+
version: "2.0.0",
124125
contact: {
125126
name: "ACM @ UIUC Infrastructure Team",
126127
@@ -357,6 +358,7 @@ async function init(prettyPrint: boolean = false, initClients: boolean = true) {
357358
await app.register(
358359
async (api, _options) => {
359360
api.register(mobileWalletV2Route, { prefix: "/mobileWallet" });
361+
api.register(membershipV2Plugin, { prefix: "/membership" });
360362
},
361363
{ prefix: "/api/v2" },
362364
);

src/api/mockLoader.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function resolve(specifier, context, defaultResolve) {
2+
// If the import is for a .png file
3+
if (specifier.endsWith(".png")) {
4+
return {
5+
// Short-circuit the import and provide a dummy module
6+
shortCircuit: true,
7+
// A data URL for a valid, empty JavaScript module
8+
url: "data:text/javascript,export default {};",
9+
};
10+
}
11+
12+
// Let Node's default loader handle all other files
13+
return defaultResolve(specifier, context, defaultResolve);
14+
}

src/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@
7171
"nodemon": "^3.1.10",
7272
"pino-pretty": "^13.0.0"
7373
}
74-
}
74+
}

src/api/routes/membership.ts

Lines changed: 0 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -71,139 +71,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
7171
duration: 30,
7272
rateLimitIdentifier: "membership",
7373
});
74-
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
75-
"/checkout/:netId",
76-
{
77-
schema: withTags(["Membership"], {
78-
params: z.object({ netId: illinoisNetId }),
79-
summary:
80-
"Create a checkout session to purchase an ACM @ UIUC membership.",
81-
response: {
82-
200: {
83-
description: "Stripe checkout link.",
84-
content: {
85-
"text/plain": {
86-
schema: z.url().meta({
87-
example:
88-
"https://buy.stripe.com/test_14A00j9Hq9tj9ZfchM3AY0s",
89-
}),
90-
},
91-
},
92-
},
93-
},
94-
}),
95-
},
96-
async (request, reply) => {
97-
const netId = request.params.netId.toLowerCase();
98-
const cacheKey = `membership:${netId}:acmpaid`;
99-
const result = await getKey<{ isMember: boolean }>({
100-
redisClient: fastify.redisClient,
101-
key: cacheKey,
102-
logger: request.log,
103-
});
104-
if (result && result.isMember) {
105-
throw new ValidationError({
106-
message: `${netId} is already a paid member!`,
107-
});
108-
}
109-
const isDynamoMember = await checkPaidMembershipFromTable(
110-
netId,
111-
fastify.dynamoClient,
112-
);
113-
if (isDynamoMember) {
114-
await setKey({
115-
redisClient: fastify.redisClient,
116-
key: cacheKey,
117-
data: JSON.stringify({ isMember: true }),
118-
expiresIn: MEMBER_CACHE_SECONDS,
119-
logger: request.log,
120-
});
121-
throw new ValidationError({
122-
message: `${netId} is already a paid member!`,
123-
});
124-
}
125-
const entraIdToken = await getEntraIdToken({
126-
clients: await getAuthorizedClients(),
127-
clientId: fastify.environmentConfig.AadValidClientId,
128-
secretName: genericConfig.EntraSecretName,
129-
logger: request.log,
130-
});
131-
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
132-
const isAadMember = await checkPaidMembershipFromEntra(
133-
netId,
134-
entraIdToken,
135-
paidMemberGroup,
136-
);
137-
if (isAadMember) {
138-
await setKey({
139-
redisClient: fastify.redisClient,
140-
key: cacheKey,
141-
data: JSON.stringify({ isMember: true }),
142-
expiresIn: MEMBER_CACHE_SECONDS,
143-
logger: request.log,
144-
});
145-
reply
146-
.header("X-ACM-Data-Source", "aad")
147-
.send({ netId, isPaidMember: true });
148-
await setPaidMembershipInTable(netId, fastify.dynamoClient);
149-
throw new ValidationError({
150-
message: `${netId} is already a paid member!`,
151-
});
152-
}
153-
// Once the caller becomes a member, the stripe webhook will handle changing this to true
154-
await setKey({
155-
redisClient: fastify.redisClient,
156-
key: cacheKey,
157-
data: JSON.stringify({ isMember: false }),
158-
expiresIn: MEMBER_CACHE_SECONDS,
159-
logger: request.log,
160-
});
161-
const secretApiConfig =
162-
(await getSecretValue(
163-
fastify.secretsManagerClient,
164-
genericConfig.ConfigSecretName,
165-
)) || {};
166-
if (!secretApiConfig) {
167-
throw new InternalServerError({
168-
message: "Could not connect to Stripe.",
169-
});
170-
}
171-
return reply.status(200).send(
172-
await createCheckoutSession({
173-
successUrl: "https://acm.illinois.edu/paid",
174-
returnUrl: "https://acm.illinois.edu/membership",
175-
customerEmail: `${netId}@illinois.edu`,
176-
stripeApiKey: secretApiConfig.stripe_secret_key as string,
177-
items: [
178-
{
179-
price: fastify.environmentConfig.PaidMemberPriceId,
180-
quantity: 1,
181-
},
182-
],
183-
customFields: [
184-
{
185-
key: "firstName",
186-
label: {
187-
type: "custom",
188-
custom: "Member First Name",
189-
},
190-
type: "text",
191-
},
192-
{
193-
key: "lastName",
194-
label: {
195-
type: "custom",
196-
custom: "Member Last Name",
197-
},
198-
type: "text",
199-
},
200-
],
201-
initiator: "purchase-membership",
202-
allowPromotionCodes: true,
203-
}),
204-
);
205-
},
206-
);
20774
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
20875
"/:netId",
20976
{

src/api/routes/v2/membership.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
checkExternalMembership,
3+
checkPaidMembershipFromEntra,
4+
checkPaidMembershipFromTable,
5+
setPaidMembershipInTable,
6+
MEMBER_CACHE_SECONDS,
7+
checkPaidMembershipFromRedis,
8+
} from "api/functions/membership.js";
9+
import { FastifyPluginAsync } from "fastify";
10+
import {
11+
BaseError,
12+
InternalServerError,
13+
UnauthenticatedError,
14+
ValidationError,
15+
} from "common/errors/index.js";
16+
import { getEntraIdToken } from "api/functions/entraId.js";
17+
import { genericConfig, roleArns } from "common/config.js";
18+
import { getRoleCredentials } from "api/functions/sts.js";
19+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
20+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
21+
import rateLimiter from "api/plugins/rateLimiter.js";
22+
import { createCheckoutSession } from "api/functions/stripe.js";
23+
import { getSecretValue } from "api/plugins/auth.js";
24+
import stripe, { Stripe } from "stripe";
25+
import rawbody from "fastify-raw-body";
26+
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
27+
import * as z from "zod/v4";
28+
import {
29+
illinoisNetId,
30+
notAuthenticatedError,
31+
withTags,
32+
} from "api/components/index.js";
33+
import { getKey, setKey } from "api/functions/redisCache.js";
34+
import { verifyUiucIdToken } from "./mobileWallet.js";
35+
36+
function splitOnce(s: string, on: string) {
37+
const [first, ...rest] = s.split(on);
38+
return [first, rest.length > 0 ? rest.join(on) : null];
39+
}
40+
function trim(s: string) {
41+
return (s || "").replace(/^\s+|\s+$/g, "");
42+
}
43+
44+
const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
45+
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
46+
await fastify.register(rateLimiter, {
47+
limit: 15,
48+
duration: 30,
49+
rateLimitIdentifier: "membershipV2",
50+
});
51+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
52+
"/checkout",
53+
{
54+
schema: withTags(["Membership"], {
55+
headers: z.object({
56+
"x-uiuc-id-token": z.jwt().min(1).meta({
57+
description:
58+
"An ID token for the user in the UIUC Entra ID tenant.",
59+
}),
60+
}),
61+
summary:
62+
"Create a checkout session to purchase an ACM @ UIUC membership.",
63+
response: {
64+
200: {
65+
description: "Stripe checkout link.",
66+
content: {
67+
"text/plain": {
68+
schema: z.url().meta({
69+
example:
70+
"https://buy.stripe.com/test_14A00j9Hq9tj9ZfchM3AY0s",
71+
}),
72+
},
73+
},
74+
},
75+
403: notAuthenticatedError,
76+
},
77+
}),
78+
},
79+
async (request, reply) => {
80+
const idToken = request.headers["x-uiuc-id-token"];
81+
const verifiedData = await verifyUiucIdToken({
82+
idToken,
83+
redisClient: fastify.redisClient,
84+
logger: request.log,
85+
});
86+
const { preferred_username: upn, email, name } = verifiedData;
87+
const netId = upn.replace("@illinois.edu", "");
88+
if (netId.includes("@")) {
89+
request.log.error(
90+
`Found UPN ${upn} which cannot be turned into NetID via simple replacement.`,
91+
);
92+
throw new ValidationError({
93+
message: "ID token could not be parsed.",
94+
});
95+
}
96+
let isPaidMember = await checkPaidMembershipFromRedis(
97+
netId,
98+
fastify.redisClient,
99+
request.log,
100+
);
101+
if (isPaidMember === null) {
102+
isPaidMember = await checkPaidMembershipFromTable(
103+
netId,
104+
fastify.dynamoClient,
105+
);
106+
}
107+
if (isPaidMember) {
108+
throw new ValidationError({
109+
message: `${upn} is already a paid member.`,
110+
});
111+
}
112+
let firstName: string = "";
113+
let lastName: string = "";
114+
if (!name.includes(",")) {
115+
const splitted = splitOnce(name, " ");
116+
firstName = splitted[0] || "";
117+
lastName = splitted[1] || "";
118+
}
119+
firstName = trim(name.split(",")[1]);
120+
lastName = name.split(",")[0];
121+
122+
return reply.status(200).send(
123+
await createCheckoutSession({
124+
successUrl: "https://acm.illinois.edu/paid",
125+
returnUrl: "https://acm.illinois.edu/membership",
126+
customerEmail: upn,
127+
stripeApiKey: fastify.secretConfig.stripe_secret_key as string,
128+
items: [
129+
{
130+
price: fastify.environmentConfig.PaidMemberPriceId,
131+
quantity: 1,
132+
},
133+
],
134+
customFields: [
135+
{
136+
key: "firstName",
137+
label: {
138+
type: "custom",
139+
custom: "Member First Name",
140+
},
141+
type: "text",
142+
text: {
143+
default_value: firstName,
144+
},
145+
},
146+
{
147+
key: "lastName",
148+
label: {
149+
type: "custom",
150+
custom: "Member Last Name",
151+
},
152+
type: "text",
153+
text: {
154+
default_value: lastName,
155+
},
156+
},
157+
],
158+
initiator: "purchase-membership",
159+
allowPromotionCodes: true,
160+
}),
161+
);
162+
},
163+
);
164+
};
165+
fastify.register(limitedRoutes);
166+
};
167+
168+
export default membershipV2Plugin;

0 commit comments

Comments
 (0)