diff --git a/packages/api/package.json b/packages/api/package.json index c9bdf9f5..19f6a3b6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -13,7 +13,8 @@ "./middleware/cache": "./src/middleware/cache.ts", "./middleware/http-security": "./src/middleware/http-security.ts", "./middleware/security": "./src/middleware/security.ts", - "./trpc": "./src/trpc.ts" + "./trpc": "./src/trpc.ts", + "./pricing": "./src/services/pricing.ts" }, "scripts": { "lint": "eslint . --max-warnings 0", diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 71b779d6..8867983b 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -1380,8 +1380,8 @@ describe("Router Integration and Access Control Verification Suite", () => { describe("12. Stripe Payments & Account Linking", () => { it("should create checkout session in mock development mode", async () => { const ctx = createMockCtx("stripe_user_id"); - const origKey = process.env.STRIPE_SECRET_KEY; - process.env.STRIPE_SECRET_KEY = "mk_test_123456"; + const origMockMode = process.env.STRIPE_MOCK_MODE; + process.env.STRIPE_MOCK_MODE = "true"; mockFindFirst.mockImplementation((table) => { if (table === "users") { @@ -1398,7 +1398,8 @@ describe("Router Integration and Access Control Verification Suite", () => { returnUrl: "https://datasciencegt.org/portal", }); - process.env.STRIPE_SECRET_KEY = origKey; + if (origMockMode === undefined) delete process.env.STRIPE_MOCK_MODE; + else process.env.STRIPE_MOCK_MODE = origMockMode; expect(res.url).toContain("payment=success"); }); diff --git a/packages/api/src/.internal-tests/stripe-payments.test.ts b/packages/api/src/.internal-tests/stripe-payments.test.ts index 5cf6fd2f..395d5cfb 100644 --- a/packages/api/src/.internal-tests/stripe-payments.test.ts +++ b/packages/api/src/.internal-tests/stripe-payments.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { appRouter } from "../root"; import { cache } from "../middleware/cache"; import { db } from "@query/db"; +import { MEMBERSHIP_CENTS, BOOTCAMP_ADDON_CENTS } from "../services/pricing"; /** * Membership payment flow. @@ -83,6 +84,10 @@ describe("Membership payments", () => { beforeEach(() => { vi.clearAllMocks(); cache.clear(); + // Both are set per-test; clearing here keeps one test's mode from leaking + // into the next. + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_MOCK_MODE; mockFindFirst.mockImplementation((table) => { // Membership rows hang off a hackathon, so checkout needs one to exist. if (table === "users") @@ -95,6 +100,7 @@ describe("Membership payments", () => { afterEach(() => { process.env.STRIPE_SECRET_KEY = originalKey; process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = originalPublishable; + delete process.env.STRIPE_MOCK_MODE; }); const caller = () => @@ -125,7 +131,7 @@ describe("Membership payments", () => { /currently unavailable/i, ); - process.env.STRIPE_SECRET_KEY = "mk_test_recovers"; + process.env.STRIPE_MOCK_MODE = "true"; const result = await caller().stripe.createPaymentIntent(); expect(result.isMock).toBe(true); @@ -135,7 +141,7 @@ describe("Membership payments", () => { describe("mock mode", () => { it("returns a mock client secret without touching the Stripe SDK", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_local"; const result = await caller().stripe.createPaymentIntent(); @@ -148,7 +154,7 @@ describe("Membership payments", () => { }); it("falls back to a placeholder publishable key when none is set", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; delete process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; const result = await caller().stripe.createPaymentIntent(); @@ -158,7 +164,7 @@ describe("Membership payments", () => { }); it("completes a mock checkout session and returns a success URL", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; const result = await caller().stripe.createCheckoutSession({ returnUrl: RETURN_URL, @@ -169,7 +175,7 @@ describe("Membership payments", () => { }); it("appends the session with & when the return URL already has a query", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; const result = await caller().stripe.createCheckoutSession({ returnUrl: `${RETURN_URL}?tab=membership`, @@ -180,11 +186,11 @@ describe("Membership payments", () => { /** * Mock mode writes paymentStatus "paid" and activates a membership without - * any money moving. .env.production ships an `mk_` key, so if the key - * prefix alone enabled it, any signed-in user could call this endpoint and - * grant themselves a paid membership on the live site. + * any money moving, so a production build must ignore the flag entirely — + * otherwise one stray environment variable turns "grant myself a paid + * membership" into a single authenticated request on the live site. */ - describe("with a mock key on a production build", () => { + describe("with the mock flag set on a production build", () => { // NODE_ENV is typed readonly, so it is set through the record itself. const env = process.env as Record; const realNodeEnv = env.NODE_ENV; @@ -198,7 +204,7 @@ describe("Membership payments", () => { }); it("does not hand out a membership from a mock checkout session", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; await expect( caller().stripe.createCheckoutSession({ returnUrl: RETURN_URL }), @@ -209,7 +215,7 @@ describe("Membership payments", () => { }); it("does not hand out a mock client secret", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; await expect(caller().stripe.createPaymentIntent()).rejects.toThrow( /unavailable/i, @@ -220,7 +226,7 @@ describe("Membership payments", () => { describe("input and account preconditions", () => { it("rejects a return URL that is not a URL", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; await expect( caller().stripe.createCheckoutSession({ returnUrl: "not-a-url" }), @@ -228,7 +234,7 @@ describe("Membership payments", () => { }); it("refuses checkout for a user with no email on file", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; mockFindFirst.mockImplementation((table) => table === "users" ? { id: USER, email: null, name: "No Email" } : undefined, ); @@ -239,7 +245,7 @@ describe("Membership payments", () => { }); it("requires a signed-in user", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + process.env.STRIPE_MOCK_MODE = "true"; const anonymous = appRouter.createCaller({ db, session: null, @@ -256,17 +262,32 @@ describe("Membership payments", () => { }); describe("membership price", () => { - it("records $15.00 for a mock membership payment", async () => { - process.env.STRIPE_SECRET_KEY = "mk_test_local"; + const insertedAmount = () => + ( + mockInsert.mock.calls.flat(2).find( + (arg: any) => arg && typeof arg === "object" && "amountTotal" in arg, + ) as { amountTotal?: number } | undefined + )?.amountTotal; + + it("records the membership price for a mock payment", async () => { + process.env.STRIPE_MOCK_MODE = "true"; await caller().stripe.createCheckoutSession({ returnUrl: RETURN_URL }); - // The inserted payment row must agree with the $15 the portal advertises. - const inserted = mockInsert.mock.calls.flat(2).find( - (arg: any) => arg && typeof arg === "object" && "amountTotal" in arg, - ) as { amountTotal?: number } | undefined; + // Reads from the shared pricing module rather than a literal, so this + // cannot drift from what the portal quotes the way $15 vs $25 once did. + expect(insertedAmount()).toBe(MEMBERSHIP_CENTS); + }); + + it("adds the bootcamp fee on top when it is requested", async () => { + process.env.STRIPE_MOCK_MODE = "true"; + + await caller().stripe.createCheckoutSession({ + returnUrl: RETURN_URL, + bootcamp: true, + }); - expect(inserted?.amountTotal).toBe(1500); + expect(insertedAmount()).toBe(MEMBERSHIP_CENTS + BOOTCAMP_ADDON_CENTS); }); }); }); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 896f9ecb..0ae7dc62 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -9,4 +9,11 @@ export { clearMembershipCaches, } from "./middleware/cache"; export { resolveHackathonId } from "./services/portal-context"; +export { + MEMBERSHIP_CENTS, + BOOTCAMP_ADDON_CENTS, + MAX_MEMBERSHIP_CHARGE_CENTS, + priceForCents, + formatCents, +} from "./services/pricing"; export type { PortalContext, MemberContext } from "./types/portal-context"; diff --git a/packages/api/src/routers/stripe.ts b/packages/api/src/routers/stripe.ts index ec513f47..f0e45478 100644 --- a/packages/api/src/routers/stripe.ts +++ b/packages/api/src/routers/stripe.ts @@ -1,12 +1,20 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { stripePayments, userAccountLinks, members, users } from "@query/db"; +import { stripePayments, userAccountLinks, users } from "@query/db"; import type { DrizzleDB } from "@query/db"; import { eq, and, isNull } from "drizzle-orm"; import { logSecurityEvent } from "../middleware/security"; import { clearMembershipCaches as clearMembershipCachesFor } from "../middleware/cache"; -import { resolveHackathonId } from "../services/portal-context"; +import { + createOrUpdateMembership, + paidForBootcamp, +} from "@query/db/services/membership"; +import { + priceForCents, + formatCents, + MAX_MEMBERSHIP_CHARGE_CENTS, +} from "../services/pricing"; import type Stripe from "stripe"; import crypto from "crypto"; @@ -15,17 +23,22 @@ let stripeClientKey: string | undefined; /** * Mock mode is a local-development affordance: it records a paid payment and - * activates a membership without any money moving. That must never be - * reachable from a production deploy — .env.production currently carries an - * `mk_` key, which would otherwise make "grant myself a paid membership" a - * single authenticated request to createCheckoutSession. + * activates a membership without any money moving. * - * `next build`/`next start` set NODE_ENV to production, so a deploy that still - * has a mock key now fails closed with "payment service unavailable" instead - * of handing out memberships. + * Switched on by an explicit flag, never by a key prefix. This used to trigger + * on a secret key starting with `mk_` — a prefix Stripe does not issue and + * this repo invented. That looked enough like a credential to get committed to + * an environment file and shipped to production, where it silently put the + * live site into mock mode. A flag named STRIPE_MOCK_MODE cannot be mistaken + * for a key. + * + * `next build`/`next start` set NODE_ENV to production, so the flag is ignored + * there no matter how it is set — a production deploy fails closed with + * "payment service unavailable" rather than handing out free memberships. */ -const isMockMode = (key: string | undefined): key is string => - !!key && key.startsWith("mk_") && process.env.NODE_ENV !== "production"; +const isMockMode = () => + process.env.STRIPE_MOCK_MODE === "true" && + process.env.NODE_ENV !== "production"; /** * The secret and publishable keys must be the same Stripe mode. @@ -55,13 +68,22 @@ const assertKeyModesMatch = (secretKey: string) => { } }; +/** + * Why payments are unavailable, for the server log. + * + * "Payment service is currently unavailable" is the right thing to show a + * member, but it is useless to whoever has to fix it: a missing key and a mock + * key in production look identical from the outside. This names the cause + * without ever putting key material in a log. + */ +const describeKeyProblem = (key: string | undefined) => { + if (!key) return "STRIPE_SECRET_KEY is not set on this deployment"; + return null; +}; + async function getStripe(): Promise { const key = process.env.STRIPE_SECRET_KEY; - // A mock key cannot talk to Stripe; constructing a client with it would turn - // every call into an opaque auth error instead of a clear outage. - if (key?.startsWith("mk_")) return null; - // Only a successfully constructed client is memoized, and only for the key it // was built from. Previously a single call made before the environment was // populated cached `null` for the lifetime of the process, so every later @@ -86,19 +108,13 @@ export const stripeRouter = createTRPCRouter({ * Create a new Stripe Checkout Session for membership */ createCheckoutSession: protectedProcedure - .input(z.object({ returnUrl: z.string().url() })) + .input( + z.object({ + returnUrl: z.string().url(), + bootcamp: z.boolean().default(false), + }), + ) .mutation(async ({ ctx, input }) => { - // Check key presence up front — cheap, and keeps the original error - // precedence without paying for the Stripe SDK import. - const stripeKey = process.env.STRIPE_SECRET_KEY; - if (!stripeKey) { - throw new TRPCError({ - code: "SERVICE_UNAVAILABLE", - message: - "Payment service is currently unavailable. Please try again later.", - }); - } - const user = await ctx.db!.query.users.findFirst({ where: eq((await import("@query/db")).users.id, ctx.userId!), }); @@ -110,13 +126,15 @@ export const stripeRouter = createTRPCRouter({ }); } - // Mock mode short-circuits before getStripe(), so a development key never - // dynamically imports and instantiates the real Stripe SDK. - if (isMockMode(stripeKey)) { + // Checked before the key, so local development needs no Stripe key at + // all — which is the point: there is no longer any reason to invent a + // placeholder credential. + if (isMockMode()) { const sessionId = `cs_mock_${crypto.randomUUID().replace(/-/g, "")}`; const nameParts = (user.name || "Member").split(" "); const firstName = nameParts[0] || "Member"; const lastName = nameParts.slice(1).join(" ") || "Member"; + const bootcampMember = input.bootcamp; await ctx.db!.transaction(async (tx) => { await tx.insert(stripePayments).values({ @@ -125,21 +143,23 @@ export const stripeRouter = createTRPCRouter({ stripePaymentIntentId: "pi_mock_123", customerEmail: user.email!.toLowerCase(), customerName: user.name || "Member", - // $15.00, matching the live checkout line item and the portal UI. - amountTotal: 1500, + amountTotal: priceForCents(input.bootcamp), currency: "usd", paymentStatus: "paid", linkedUserId: ctx.userId!, linkedAt: new Date(), - metadata: JSON.stringify({ userId: ctx.userId! }), + metadata: JSON.stringify({ + userId: ctx.userId!, + bootcamp: input.bootcamp ? "true" : "false", + }), }); - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, firstName, lastName, - ); + bootcampMember, + }); }); // Invalidate cache @@ -149,10 +169,29 @@ export const stripeRouter = createTRPCRouter({ return { url: mockUrl }; } + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? "unknown", + details: `Stripe unavailable: ${describeKeyProblem(stripeKey) ?? "unknown cause"}`, + }); + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: + "Payment service is currently unavailable. Please try again later.", + }); + } + assertKeyModesMatch(stripeKey); const stripe = await getStripe(); if (!stripe) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? "unknown", + details: `Stripe unavailable: ${describeKeyProblem(process.env.STRIPE_SECRET_KEY) ?? "unknown cause"}`, + }); throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: @@ -170,15 +209,16 @@ export const stripeRouter = createTRPCRouter({ price_data: { currency: "usd", product_data: { - name: "DSGT Membership", - description: - "One year membership to Data Science at Georgia Tech", - // images: ["https://example.com/logo.png"], // Optional: Add a logo if available + name: input.bootcamp + ? "DSGT Membership + Bootcamp" + : "DSGT Membership", + description: input.bootcamp + ? "One year membership to Data Science at Georgia Tech, including bootcamp access" + : "One year membership to Data Science at Georgia Tech", }, - // $15.00 — matches createPaymentIntent (1500) and the portal UI - // ("$15.00 / year"). This was 2500, so the hosted-checkout path - // charged $25 for the same membership. - unit_amount: 1500, + // From the shared pricing module, so this can no longer drift + // from what createPaymentIntent charges or the portal quotes. + unit_amount: priceForCents(input.bootcamp), }, quantity: 1, }, @@ -189,6 +229,7 @@ export const stripeRouter = createTRPCRouter({ customer_email: user.email, metadata: { userId: ctx.userId!, + bootcamp: input.bootcamp ? "true" : "false", }, }); @@ -215,15 +256,8 @@ export const stripeRouter = createTRPCRouter({ * Returns client_secret for use with Stripe Payment Element */ createPaymentIntent: protectedProcedure - .mutation(async ({ ctx }) => { - const stripeKey = process.env.STRIPE_SECRET_KEY; - if (!stripeKey) { - throw new TRPCError({ - code: "SERVICE_UNAVAILABLE", - message: "Payment service is currently unavailable. Please try again later.", - }); - } - + .input(z.object({ bootcamp: z.boolean().default(false) }).default({})) + .mutation(async ({ ctx, input }) => { const user = await ctx.db!.query.users.findFirst({ where: eq((await import("@query/db")).users.id, ctx.userId!), }); @@ -235,10 +269,9 @@ export const stripeRouter = createTRPCRouter({ }); } - // Dev/mock mode short-circuits before getStripe(), matching - // createCheckoutSession. This previously ran *after* getStripe(), so a - // mock key still constructed a real Stripe SDK instance. - if (isMockMode(stripeKey)) { + // Checked before the key, matching createCheckoutSession, so local + // development needs no Stripe key at all. + if (isMockMode()) { return { clientSecret: "mock_pi_secret", publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "pk_test_mock", @@ -246,10 +279,28 @@ export const stripeRouter = createTRPCRouter({ }; } + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? "unknown", + details: `Stripe unavailable: ${describeKeyProblem(stripeKey) ?? "unknown cause"}`, + }); + throw new TRPCError({ + code: "SERVICE_UNAVAILABLE", + message: "Payment service is currently unavailable. Please try again later.", + }); + } + assertKeyModesMatch(stripeKey); const stripe = await getStripe(); if (!stripe) { + logSecurityEvent({ + type: "validation_error", + identifier: ctx.userId ?? "unknown", + details: `Stripe unavailable: ${describeKeyProblem(process.env.STRIPE_SECRET_KEY) ?? "unknown cause"}`, + }); throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Payment service is currently unavailable. Please try again later.", @@ -257,15 +308,23 @@ export const stripeRouter = createTRPCRouter({ } try { + const amount = priceForCents(input.bootcamp); + const paymentIntent = await stripe.paymentIntents.create({ - amount: 1500, // $15.00 in cents + amount, currency: "usd", receipt_email: user.email, - description: "DSGT Annual Membership ($15/yr)", + description: input.bootcamp + ? `DSGT Annual Membership + Bootcamp (${formatCents(amount)}/yr)` + : `DSGT Annual Membership (${formatCents(amount)}/yr)`, metadata: { userId: ctx.userId!, userEmail: user.email, type: "membership", + // Read back when the payment is recorded, so the bootcamp flag + // comes from what was actually charged rather than from a client + // that could simply claim it. + bootcamp: input.bootcamp ? "true" : "false", }, automatic_payment_methods: { enabled: true }, }); @@ -331,6 +390,9 @@ export const stripeRouter = createTRPCRouter({ const nameParts = (user?.name || "Member").split(" "); const firstName = nameParts[0] ?? "Member"; const lastName = nameParts.slice(1).join(" ") || "Member"; + // From the charged intent, not the client: the add-on is only granted + // if it was actually paid for. + const bootcampMember = pi.metadata?.bootcamp === "true"; // Check if already processed (idempotent) const existing = await ctx.db!.query.stripePayments.findFirst({ @@ -353,19 +415,19 @@ export const stripeRouter = createTRPCRouter({ metadata: JSON.stringify(pi.metadata ?? {}), }); - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, firstName, lastName, - ); + bootcampMember, + }); }); } else if (!existing.linkedUserId) { // Payment exists but wasn't linked — link it now await ctx.db!.update(stripePayments) .set({ linkedUserId: ctx.userId!, linkedAt: new Date(), updatedAt: new Date() }) .where(eq(stripePayments.id, existing.id)); - await createOrUpdateMembership(ctx.db! as DrizzleDB, ctx.userId!, firstName, lastName); + await createOrUpdateMembership(ctx.db! as DrizzleDB, { userId: ctx.userId!, firstName, lastName, bootcampMember }); } clearMembershipCaches(ctx.cache, ctx.userId!); @@ -414,6 +476,7 @@ export const stripeRouter = createTRPCRouter({ const names = (user.name || "Member").split(" "); const firstName = names[0] || "Member"; const lastName = names.slice(1).join(" ") || "Member"; + const bootcampMember = paidForBootcamp(payment.metadata); await tx.insert(userAccountLinks).values({ userId: ctx.userId!, @@ -432,12 +495,12 @@ export const stripeRouter = createTRPCRouter({ }) .where(eq(stripePayments.id, payment.id)); - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, - firstName, - lastName, - ); + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, + firstName, + lastName, + bootcampMember, + }); clearMembershipCaches(ctx.cache, ctx.userId!); @@ -496,7 +559,7 @@ export const stripeRouter = createTRPCRouter({ if (pi.metadata?.userId !== ctx.userId) continue; // Same ceiling the webhook applies, so the two paths cannot disagree // about which charges are memberships. - if (pi.amount > 10000) continue; + if (pi.amount > MAX_MEMBERSHIP_CHARGE_CENTS) continue; const existing = await ctx.db!.query.stripePayments.findFirst({ where: eq(stripePayments.stripePaymentIntentId, pi.id), @@ -528,17 +591,18 @@ export const stripeRouter = createTRPCRouter({ if (claimed.rowCount === 0) return; const parts = (user?.name || "Member").trim().split(/\s+/); - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, - parts[0] || "Member", - parts.slice(1).join(" ") || "Member", - ); + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, + firstName: parts[0] || "Member", + lastName: parts.slice(1).join(" ") || "Member", + bootcampMember: pi.metadata?.bootcamp === "true", + }); recovered += 1; }); continue; } + const bootcampMember = pi.metadata?.bootcamp === "true"; const { firstName, lastName } = (() => { const parts = (user?.name || "Member").trim().split(/\s+/); return { @@ -578,12 +642,12 @@ export const stripeRouter = createTRPCRouter({ if (inserted.length === 0) return; - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, firstName, lastName, - ); + bootcampMember, + }); recovered += 1; }); } catch (error) { @@ -761,12 +825,12 @@ export const stripeRouter = createTRPCRouter({ }) .where(eq(stripePayments.id, payment.id)); - await createOrUpdateMembership( - tx as unknown as DrizzleDB, - ctx.userId!, - input.firstName, - input.lastName, - ); + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: ctx.userId!, + firstName: input.firstName, + lastName: input.lastName, + bootcampMember: paidForBootcamp(payment.metadata), + }); clearMembershipCaches(ctx.cache, ctx.userId!); @@ -800,52 +864,3 @@ export const stripeRouter = createTRPCRouter({ }), }); -async function createOrUpdateMembership( - db: DrizzleDB, - userId: string, - firstName: string, - lastName: string, -) { - const hackathonId = await resolveHackathonId(db); - - if (!hackathonId) { - throw new Error("No hackathon found for membership assignment"); - } - - const existingMember = await db.query.members.findFirst({ - where: and( - eq(members.userId, userId), - eq(members.hackathonId, hackathonId), - ), - }); - - const now = new Date(); - const oneYearFromNow = new Date(now); - oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); - - if (existingMember) { - await db - .update(members) - .set({ - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: existingMember.renewalCount + 1, - memberType: "continuous", - updatedAt: now, - }) - .where(eq(members.id, existingMember.id)); - } else { - await db.insert(members).values({ - userId, - hackathonId, - firstName, - lastName, - memberType: "new", - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: 0, - }); - } -} diff --git a/packages/api/src/services/pricing.ts b/packages/api/src/services/pricing.ts new file mode 100644 index 00000000..cc732b1e --- /dev/null +++ b/packages/api/src/services/pricing.ts @@ -0,0 +1,30 @@ +/** + * Membership pricing, in one place. + * + * These numbers previously lived inline in createPaymentIntent, in the Checkout + * line item, and in three pieces of UI copy. They drifted: hosted checkout + * charged $25 for the membership the modal and the portal both priced at $15. + * Everything that quotes or charges a price now reads from here. + * + * Cents, because that is what Stripe takes. + */ +export const MEMBERSHIP_CENTS = 2500; + +/** Charged on top of the membership, not instead of it. */ +export const BOOTCAMP_ADDON_CENTS = 1000; + +export const priceForCents = (withBootcamp: boolean) => + MEMBERSHIP_CENTS + (withBootcamp ? BOOTCAMP_ADDON_CENTS : 0); + +/** "$25.00" — for UI copy and Stripe product descriptions. */ +export const formatCents = (cents: number) => + `$${(cents / 100).toFixed(2)}`; + +/** + * Upper bound for a charge this app is willing to treat as a membership. + * + * The webhook and the reconcile path both use it to ignore unrelated activity + * on the Stripe account, so it has to sit above the most expensive thing we + * actually sell and nowhere near a real invoice. + */ +export const MAX_MEMBERSHIP_CHARGE_CENTS = 10000; diff --git a/packages/db/src/schemas/members.ts b/packages/db/src/schemas/members.ts index fbfa7ba1..6abdc14d 100644 --- a/packages/db/src/schemas/members.ts +++ b/packages/db/src/schemas/members.ts @@ -49,6 +49,12 @@ export const members = pgTable( major: text("major"), graduationYear: integer("graduation_year"), isActive: boolean("is_active").notNull().default(true), + /** + * Paid the bootcamp add-on on top of the membership. Set from the payment + * metadata, so it reflects what the member actually bought rather than + * anything a client can assert. + */ + bootcampMember: boolean("bootcamp_member").notNull().default(false), joinedAt: timestamp("joined_at").defaultNow().notNull(), membershipStartDate: timestamp("membership_start_date").notNull(), membershipEndDate: timestamp("membership_end_date"), diff --git a/packages/db/src/services/membership.test.ts b/packages/db/src/services/membership.test.ts index 25ec737f..783a0762 100644 --- a/packages/db/src/services/membership.test.ts +++ b/packages/db/src/services/membership.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { createOrUpdateMembership } from "./membership"; +import { createOrUpdateMembership, splitName } from "./membership"; import type { DrizzleDB } from "../client"; const DAY = 24 * 60 * 60 * 1000; @@ -34,6 +34,34 @@ function fakeDb(existingMember: Record | undefined) { return { db, updates, inserts }; } +describe("splitName", () => { + /** + * A copy of this in the Stripe webhook lost a backslash and split on the + * letter "s" instead of whitespace, so every name containing an s was + * mangled before being written to the members table. "Chris Smith" is the + * case that catches it — it passes under /\s+/ and fails under /s+/. + */ + it.each([ + ["Chris Smith", "Chris", "Smith"], + ["Jane Doe", "Jane", "Doe"], + ["Mary Jane Watson", "Mary", "Jane Watson"], + [" Ada Lovelace ", "Ada", "Lovelace"], + ])("splits %s on whitespace", (input, first, last) => { + expect(splitName(input)).toEqual({ firstName: first, lastName: last }); + }); + + it("falls back for a single name or nothing at all", () => { + expect(splitName("Prince")).toEqual({ + firstName: "Prince", + lastName: "Member", + }); + expect(splitName(null)).toEqual({ + firstName: "Member", + lastName: "Member", + }); + }); +}); + describe("createOrUpdateMembership", () => { it("gives a brand new member a year from today", async () => { const { db, inserts } = fakeDb(undefined); diff --git a/packages/db/src/services/membership.ts b/packages/db/src/services/membership.ts index aea950cb..f56e30aa 100644 --- a/packages/db/src/services/membership.ts +++ b/packages/db/src/services/membership.ts @@ -64,7 +64,26 @@ export async function resolveCurrentHackathonId( return resolved?.id; } -const splitName = (name: string | null | undefined) => { +/** + * Whether a stored payment's metadata says the bootcamp add-on was bought. + * Metadata is a JSON string written by whichever path recorded the payment, + * and older rows predate the field entirely, so anything unparseable is "no". + */ +export const paidForBootcamp = (metadata: string | null | undefined) => { + if (!metadata) return false; + try { + return (JSON.parse(metadata) as { bootcamp?: string }).bootcamp === "true"; + } catch { + return false; + } +}; + +/** + * Exported so no caller hand-rolls it. A copied version in the Stripe webhook + * lost a backslash and split on the letter "s" rather than whitespace, storing + * "Chris Smith" as firstName "Chri", lastName " Smith". + */ +export const splitName = (name: string | null | undefined) => { const parts = (name || "Member").trim().split(/\s+/); return { firstName: parts[0] || "Member", @@ -80,6 +99,12 @@ export async function createOrUpdateMembership( lastName: string; phoneNumber?: string | null; hackathonId?: string; + /** + * Whether this payment included the bootcamp add-on. Only ever upgrades: + * renewing without it should not silently strip access someone already + * paid for, so the flag is sticky once set. + */ + bootcampMember?: boolean; }, ) { const hackathonId = @@ -122,6 +147,7 @@ export async function createOrUpdateMembership( renewalCount: existing.renewalCount + 1, memberType: "continuous", phoneNumber: opts.phoneNumber || existing.phoneNumber, + bootcampMember: existing.bootcampMember || !!opts.bootcampMember, updatedAt: now, }) .where(eq(members.id, existing.id)); @@ -139,6 +165,7 @@ export async function createOrUpdateMembership( membershipEndDate: termEnd, renewalCount: 0, phoneNumber: opts.phoneNumber ?? null, + bootcampMember: !!opts.bootcampMember, }); } @@ -225,6 +252,9 @@ export async function linkPaidPaymentByVerifiedEmail( userId: opts.userId, firstName, lastName, + // What they paid for is recorded on the payment, so the add-on survives + // being claimed later by the sign-in hook or the backfill. + bootcampMember: paidForBootcamp(payment.metadata), }); notifyMembershipChanged(opts.userId); diff --git a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts index 8ce081c4..4b6b02a2 100644 --- a/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts +++ b/sites/mainweb/app/(portal)/api/webhooks/stripe/route.ts @@ -1,13 +1,17 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import Stripe from "stripe"; -import { db, stripePayments, users, members } from "@query/db"; +import { db, stripePayments, users } from "@query/db"; +import { + createOrUpdateMembership, + splitName, +} from "@query/db/services/membership"; import type { DrizzleDB } from "@query/db"; -import { eq, and } from "drizzle-orm"; -import { clearMembershipCaches, resolveHackathonId } from "@query/api"; - -// Type for the transaction object -type Tx = Parameters["transaction"]>[0]>[0]; +import { eq } from "drizzle-orm"; +import { + clearMembershipCaches, + MAX_MEMBERSHIP_CHARGE_CENTS, +} from "@query/api"; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; @@ -57,8 +61,7 @@ export async function POST(req: NextRequest) { */ const allowUnsignedMockEvents = process.env.NODE_ENV !== "production" && - (process.env.STRIPE_SECRET_KEY?.startsWith("mk_") || - process.env.STRIPE_WEBHOOK_SECRET?.startsWith("whsec_mock")); + process.env.STRIPE_MOCK_MODE === "true"; if (allowUnsignedMockEvents) { try { @@ -95,7 +98,7 @@ export async function POST(req: NextRequest) { try { // Check for amounts greater than $100 (10000 cents) - if (session.amount_total && session.amount_total > 10000) { + if (session.amount_total && session.amount_total > MAX_MEMBERSHIP_CHARGE_CENTS) { return NextResponse.json({ received: true }); } @@ -138,13 +141,12 @@ export async function POST(req: NextRequest) { .where(eq(stripePayments.id, existingPayment.id)); if (existingPayment.linkedUserId) { - await createOrUpdateMembership( - tx, - existingPayment.linkedUserId, - customerName, - customerEmail, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: existingPayment.linkedUserId, + ...splitName(customerName), phoneNumber, - ); + bootcampMember: session.metadata?.bootcamp === "true", + }); } }); } @@ -198,13 +200,12 @@ export async function POST(req: NextRequest) { // If user exists and paid, create/update membership if (targetUser && session.payment_status === "paid") { - await createOrUpdateMembership( - tx, - targetUser.id, - customerName, - customerEmail, + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: targetUser.id, + ...splitName(customerName), phoneNumber, - ); + bootcampMember: session.metadata?.bootcamp === "true", + }); // Invalidate cache try { @@ -232,7 +233,7 @@ export async function POST(req: NextRequest) { const pi = event.data.object as Stripe.PaymentIntent; try { - if (pi.amount > 10000) { + if (pi.amount > MAX_MEMBERSHIP_CHARGE_CENTS) { return NextResponse.json({ received: true }); } @@ -306,13 +307,11 @@ export async function POST(req: NextRequest) { if (inserted.length === 0) return; if (targetUser) { - await createOrUpdateMembership( - tx, - targetUser.id, - targetUser.name, - customerEmail, - null, - ); + await createOrUpdateMembership(tx as unknown as DrizzleDB, { + userId: targetUser.id, + ...splitName(targetUser.name), + bootcampMember: pi.metadata?.bootcamp === "true", + }); try { clearMembershipCaches(targetUser.id); @@ -329,66 +328,3 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }); } - -async function createOrUpdateMembership( - tx: Tx, - userId: string, - customerName: string | null | undefined, - customerEmail: string, - phoneNumber: string | null | undefined, -) { - // Same rule as every membership read, so a payment does not land against - // next year's draft while this year's edition is running. - const hackathonId = await resolveHackathonId(tx as unknown as DrizzleDB); - - if (!hackathonId) { - throw new Error("No hackathon found for membership assignment"); - } - - // Check if member already exists for this hackathon - const existingMember = await tx.query.members.findFirst({ - where: and( - eq(members.userId, userId), - eq(members.hackathonId, hackathonId), - ), - }); - - const now = new Date(); - const oneYearFromNow = new Date(now); - oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); - - // Parse name - const nameParts = (customerName || "Member").split(" "); - const firstName = nameParts[0] || "Member"; - const lastName = nameParts.slice(1).join(" ") || ""; - - if (existingMember) { - // Renew membership - await tx - .update(members) - .set({ - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: existingMember.renewalCount + 1, - memberType: "continuous", - updatedAt: now, - phoneNumber: phoneNumber || existingMember.phoneNumber, // Update phone if provided - }) - .where(eq(members.id, existingMember.id)); - } else { - // Create new membership - await tx.insert(members).values({ - userId, - hackathonId, - firstName, - lastName, - memberType: "new", - isActive: true, - membershipStartDate: now, - membershipEndDate: oneYearFromNow, - renewalCount: 0, - phoneNumber: phoneNumber || null, - }); - } -} diff --git a/sites/mainweb/app/(portal)/dashboard/page.tsx b/sites/mainweb/app/(portal)/dashboard/page.tsx index f78006c1..dff74444 100644 --- a/sites/mainweb/app/(portal)/dashboard/page.tsx +++ b/sites/mainweb/app/(portal)/dashboard/page.tsx @@ -8,6 +8,7 @@ import { useEffect } from "react"; import Image from "next/image"; import Link from "next/link"; import LinkStripeAccount from "@/components/portal/LinkStripeAccount"; +import { MEMBERSHIP_CENTS, formatCents } from "@query/api/pricing"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import { @@ -287,7 +288,7 @@ export default function Dashboard() {

Join DSGT as a full member for{" "} - $15/year + {formatCents(MEMBERSHIP_CENTS)}/year {" "} to unlock the Club Portal, event check-ins, and member-only resources. diff --git a/sites/mainweb/components/portal/LinkStripeAccount.tsx b/sites/mainweb/components/portal/LinkStripeAccount.tsx index e8898da0..7d5a17f0 100644 --- a/sites/mainweb/components/portal/LinkStripeAccount.tsx +++ b/sites/mainweb/components/portal/LinkStripeAccount.tsx @@ -6,6 +6,12 @@ import { trpc } from "@/lib/trpc"; import { useInvalidatePortalContext } from "@/lib/use-portal-context"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { CreditCard, Link as LinkIcon } from "lucide-react"; +import { + MEMBERSHIP_CENTS, + BOOTCAMP_ADDON_CENTS, + priceForCents, + formatCents, +} from "@query/api/pricing"; const StripePaymentModal = dynamic( () => @@ -30,6 +36,7 @@ export default function LinkStripeAccount({ email: "", }); const [error, setError] = useState(null); + const [wantsBootcamp, setWantsBootcamp] = useState(false); const [success, setSuccess] = useState(false); const [isChecking, setIsChecking] = useState(true); @@ -118,7 +125,7 @@ export default function LinkStripeAccount({ const handleOpenModal = () => { setError(null); - createIntentMutation.mutate(); + createIntentMutation.mutate({ bootcamp: wantsBootcamp }); }; const handlePaymentSuccess = () => { @@ -317,13 +324,29 @@ export default function LinkStripeAccount({ -

+

Membership verification required for portal access.{" "} - $15.00 / year + {formatCents(MEMBERSHIP_CENTS)} / year

+ {/* Add-on, priced from the same constants the server charges from. */} + +
{/* Primary: open Stripe modal */} @@ -375,6 +398,7 @@ export default function LinkStripeAccount({ await confirmMutation.mutateAsync({ paymentIntentId }); }} onUnconfirmed={handlePaymentUnconfirmed} + amountCents={priceForCents(wantsBootcamp)} onClose={() => { setShowModal(false); setPaymentData(null); diff --git a/sites/mainweb/components/portal/StripePaymentModal.tsx b/sites/mainweb/components/portal/StripePaymentModal.tsx index 7d27ae43..d0b529de 100644 --- a/sites/mainweb/components/portal/StripePaymentModal.tsx +++ b/sites/mainweb/components/portal/StripePaymentModal.tsx @@ -12,6 +12,7 @@ import type { Stripe, StripeElementsOptions } from "@stripe/stripe-js"; import { X, Shield, Lock } from "lucide-react"; import { useState } from "react"; import { useTheme } from "next-themes"; +import { formatCents } from "@query/api/pricing"; // ── Inner form (must be inside ) ───────────────────────────────── function CheckoutForm({ @@ -19,11 +20,13 @@ function CheckoutForm({ onCancel, onConfirmPayment, onUnconfirmed, + amountCents, }: { onSuccess: () => void; onCancel: () => void; onConfirmPayment: (paymentIntentId: string) => Promise; onUnconfirmed: () => void; + amountCents: number; }) { const stripe = useStripe(); const elements = useElements(); @@ -169,7 +172,7 @@ function CheckoutForm({ ) : ( <> - Pay $15.00 + Pay {formatCents(amountCents)} )} @@ -192,6 +195,7 @@ interface StripePaymentModalProps { onClose: () => void; onConfirmPayment: (paymentIntentId: string) => Promise; onUnconfirmed?: () => void; + amountCents: number; } export function StripePaymentModal({ @@ -202,6 +206,7 @@ export function StripePaymentModal({ onClose, onConfirmPayment, onUnconfirmed, + amountCents, }: StripePaymentModalProps) { const [stripePromise, setStripePromise] = useState | null>(null); @@ -287,7 +292,7 @@ export function StripePaymentModal({ // Mock mode — skip real Stripe Elements if (isMock) { return ( - +

🧪 Dev mock mode — no real charge will occur @@ -309,13 +314,14 @@ export function StripePaymentModal({ if (!stripePromise) return null; return ( - + {})} + amountCents={amountCents} /> @@ -326,9 +332,11 @@ export function StripePaymentModal({ function ModalShell({ children, onClose, + amountCents, }: { children: React.ReactNode; onClose: () => void; + amountCents: number; }) { return (

@@ -365,7 +373,7 @@ function ModalShell({ DSGT Membership

- $15.00 / year · Secure checkout + {formatCents(amountCents)} · Secure checkout

diff --git a/turbo.json b/turbo.json index bba15a8f..eadb7b5c 100644 --- a/turbo.json +++ b/turbo.json @@ -71,6 +71,7 @@ "NEXTAUTH_URL", "STRIPE_WEBHOOK_SECRET", "TRUSTED_PROXY_HOPS", + "STRIPE_MOCK_MODE", "DDOS_MAX_REQUESTS_PER_MINUTE", "DDOS_SUSPICIOUS_THRESHOLD", "DDOS_BLOCK_DURATION_MS",