Skip to content
Merged

go #312

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions packages/api/src/.internal-tests/routers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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");
});

Expand Down
63 changes: 42 additions & 21 deletions packages/api/src/.internal-tests/stripe-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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 = () =>
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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,
Expand All @@ -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`,
Expand All @@ -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<string, string | undefined>;
const realNodeEnv = env.NODE_ENV;
Expand All @@ -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 }),
Expand All @@ -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,
Expand All @@ -220,15 +226,15 @@ 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" }),
).rejects.toThrow();
});

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,
);
Expand All @@ -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,
Expand All @@ -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);
});
});
});
7 changes: 7 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading