What changed, and why

Buyers had no way to redeem a coupon when buying a run pack. The Checkout session this route builds never turned on Stripe's promotion-code field, so a valid code had nowhere to go.

The fix is one line: allow_promotion_codes: true on the session. Stripe then shows its "Add promotion code" box and does all the work server-side — matching the code to an active promotion, checking its rules, and re-pricing the session. Nothing about which runs get credited changes. The blast radius is the paid checkout path only; free play, grounding, and the game loop are untouched.

The one line, and why the money still adds up

The flag sits at the top of the sessions.create options, right beside mode: "payment". Everything below it — the line item, the metadata, the redirect URLs — is unchanged.

The new flag and its rationale; the rest of the session is as before.

server/api/checkout.post.ts · 93 lines
server/api/checkout.post.ts93 lines · TypeScript
⋯ 52 lines hidden (lines 1–52)
1/**
2 * POST /api/checkout — start a one-time purchase of a run pack. Builds a Stripe
3 * Checkout session from the SERVER-side packs catalog (never a client-supplied
4 * price or run count) and stamps the device id + run count into the session
5 * metadata, so the webhook credits exactly what was bought to the right device.
6 * Returns { url } for the client to redirect to. 503 when Stripe isn't configured.
7 */
8import { stripeClient } from "~/server/utils/stripe";
9import { currentUser } from "~/server/utils/auth";
10import { deviceId } from "~/server/utils/device";
11import { packById } from "~/server/utils/packs";
12 
13export default defineEventHandler(async (event) => {
14 if (getMethod(event) !== "POST") {
15 throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
16 }
17 const body = await readBody(event);
18 const pack = packById(typeof body?.packId === "string" ? body.packId : "");
19 if (!pack) {
20 throw createError({ statusCode: 400, statusMessage: "Unknown pack" });
21 }
22 const stripe = stripeClient();
23 if (!stripe) {
24 throw createError({
25 statusCode: 503,
26 statusMessage: "Payments unavailable",
27 });
28 }
29 
30 // Buying requires an account: an anonymous user must sign in (OAuth) first
31 // so the purchased balance is tied to a recoverable account, not a clearable
32 // cookie. When auth isn't active (no user — device-fallback mode), buying works
33 // as before with the device id.
34 // currentUser THROWS on a real auth error (vs returns null for no session), so
35 // a Supabase blip here fails the checkout instead of silently stamping the
36 // device id into Stripe and stranding the purchase under the wrong subject.
37 const user = await currentUser(event);
38 if (user?.isAnonymous) {
39 throw createError({
40 statusCode: 401,
41 statusMessage: "Sign in to buy runs",
42 });
43 }
44 // Resolve the subject from the SAME lookup — no second verify (no session ⇒
45 // device id, the legacy buying path).
46 const device = user?.id ?? deviceId(event);
47 // Pin the post-payment redirect to the configured canonical origin, NOT the
48 // client-supplied Host header (getRequestURL derives origin from Host), so a
49 // spoofed Host can't route a victim's return to an attacker. Falls back to the
50 // request origin only when unset (local dev).
51 const origin =
52 (useRuntimeConfig().siteUrl as string) || getRequestURL(event).origin;
53 try {
54 const session = await stripe.checkout.sessions.create({
55 mode: "payment",
56 // Surface Stripe's "Add promotion code" field so a buyer can redeem a
57 // coupon at checkout. Stripe validates the code and re-prices the session
58 // server-side; the webhook still credits the catalog `runs` from metadata
59 // (a discount lowers the price paid, never the runs bought), and revenue
60 // reads the actual amount_total from Stripe, so both stay correct. Mutually
61 // exclusive with a `discounts` array — which this session deliberately omits.
62 allow_promotion_codes: true,
63 line_items: [
64 {
65 price_data: {
66 currency: "usd",
67 product_data: {
68 name: `Everwhen — ${pack.label} (${pack.runs} runs)`,
69 },
70 unit_amount: pack.priceCents,
71 },
72 quantity: 1,
73 },
74 ],
75 metadata: {
76 device_id: device,
77 pack_id: pack.id,
⋯ 16 lines hidden (lines 78–93)
78 runs: String(pack.runs),
79 },
80 // Return to the board (the game lives at /play), where the purchase=*
81 // query drives the confirmation + a live balance refresh.
82 success_url: `${origin}/play?purchase=success`,
83 cancel_url: `${origin}/play?purchase=cancel`,
84 });
85 return { url: session.url };
86 } catch (error) {
87 console.error("Checkout session creation failed:", error);
88 throw createError({
89 statusCode: 502,
90 statusMessage: "Could not start checkout",
91 });
92 }
93});

The test that pins it

The spec drives the real route handler with Stripe mocked at the boundary, then reads back the exact options passed to sessions.create. The new case asserts the flag is present and true. It discriminates: on the pre-fix code the field is undefined, so toBe(true) fails — the test only passes because the flag is actually sent.

The new case sits beside the metadata test and reuses its arg-capture pattern.

tests/unit/server/api/checkout.spec.ts · 143 lines
tests/unit/server/api/checkout.spec.ts143 lines · TypeScript
⋯ 117 lines hidden (lines 1–117)
1import { describe, it, expect, vi, beforeEach } from "vitest";
2 
3import handler from "~/server/api/checkout.post";
4 
5/**
6 * POST /api/checkout — starts a Stripe Checkout from the SERVER packs catalog (#241).
7 * The contracts that matter: an anonymous buyer is 401'd (a purchase must tie to a
8 * recoverable account); the post-payment redirect is pinned to the configured origin,
9 * NOT a client-supplied (spoofable) Host; and the session metadata carries the catalog
10 * run count so the webhook credits exactly what was bought.
11 */
12const cfg: { siteUrl: string } = { siteUrl: "https://playeverwhen.com" };
13const PACK = { id: "pack-3", label: "Trio", runs: 3, priceCents: 300 };
14 
15const { createSession, stripeClient, currentUser, packById } = vi.hoisted(
16 () => {
17 // @ts-expect-error — Nitro/h3 globals the handler calls.
18 globalThis.defineEventHandler = (fn: unknown) => fn;
19 // @ts-expect-error
20 globalThis.getMethod = (e: { method?: string }) => e.method ?? "POST";
21 // @ts-expect-error
22 globalThis.readBody = (e: { body?: unknown }) => e.body;
23 // @ts-expect-error
24 globalThis.createError = (o: {
25 statusCode: number;
26 statusMessage?: string;
27 }) => Object.assign(new Error(o.statusMessage ?? "error"), o);
28 // @ts-expect-error — siteUrl comes from runtimeConfig; the Host (getRequestURL) is spoofable.
29 globalThis.useRuntimeConfig = () => cfg;
30 // @ts-expect-error — a spoofed Host header, to prove it's NOT used when siteUrl is set.
31 globalThis.getRequestURL = () =>
32 new URL("https://spoofed.attacker.example/api/checkout");
33 // Separate consts with explicit return types — an object literal where stripeClient's
34 // return references createSession trips TS's circular-inference guard.
35 const createSession = vi.fn(
36 (_opts: Record<string, unknown>): Promise<{ url: string }> =>
37 Promise.resolve({ url: "https://stripe.test/cs_1" }),
38 );
39 const stripeClient = vi.fn((): unknown => ({
40 checkout: { sessions: { create: createSession } },
41 }));
42 const currentUser = vi.fn(
43 (): Promise<{ id?: string; isAnonymous?: boolean } | null> =>
44 Promise.resolve(null),
45 );
46 const packById = vi.fn((id: string): typeof PACK | undefined =>
47 id === PACK.id ? PACK : undefined,
48 );
49 return { createSession, stripeClient, currentUser, packById };
50 },
51);
52vi.mock("~/server/utils/stripe", () => ({ stripeClient }));
53vi.mock("~/server/utils/auth", () => ({ currentUser }));
54vi.mock("~/server/utils/device", () => ({ deviceId: () => "device-xyz" }));
55vi.mock("~/server/utils/packs", () => ({ packById }));
56 
57const run = (e: unknown) =>
58 (handler as unknown as (e: unknown) => Promise<unknown>)(e);
59const event = (packId: unknown = PACK.id) => ({
60 method: "POST",
61 body: { packId },
62});
63 
64beforeEach(() => {
65 cfg.siteUrl = "https://playeverwhen.com";
66 createSession
67 .mockReset()
68 .mockResolvedValue({ url: "https://stripe.test/cs_1" });
69 stripeClient
70 .mockReset()
71 .mockReturnValue({ checkout: { sessions: { create: createSession } } });
72 currentUser.mockReset().mockResolvedValue(null);
73 packById
74 .mockReset()
75 .mockImplementation((id: string) => (id === PACK.id ? PACK : undefined));
76});
77 
78describe("POST /api/checkout (#241)", () => {
79 it("400s an unknown packId (never a client-supplied price)", async () => {
80 await expect(run(event("bogus"))).rejects.toMatchObject({
81 statusCode: 400,
82 });
83 expect(createSession).not.toHaveBeenCalled();
84 });
85 
86 it("503s when Stripe is not configured", async () => {
87 stripeClient.mockReturnValueOnce(null);
88 await expect(run(event())).rejects.toMatchObject({ statusCode: 503 });
89 });
90 
91 it("401s an anonymous buyer — a purchase must tie to a recoverable account", async () => {
92 currentUser.mockResolvedValue({ id: "anon-1", isAnonymous: true });
93 await expect(run(event())).rejects.toMatchObject({ statusCode: 401 });
94 expect(createSession).not.toHaveBeenCalled();
95 });
96 
97 it("pins the redirect to the configured origin, ignoring a spoofed Host", async () => {
98 await run(event());
99 const arg = createSession.mock.calls[0][0] as {
100 success_url: string;
101 cancel_url: string;
102 };
103 expect(arg.success_url).toBe(
104 "https://playeverwhen.com/play?purchase=success",
105 );
106 expect(arg.success_url).not.toContain("attacker");
107 });
108 
109 it("falls back to the request origin only when siteUrl is unset (local dev)", async () => {
110 cfg.siteUrl = "";
111 await run(event());
112 const arg = createSession.mock.calls[0][0] as { success_url: string };
113 expect(arg.success_url).toBe(
114 "https://spoofed.attacker.example/play?purchase=success",
115 );
116 });
117 
118 it("stamps the catalog run count + device into the session metadata", async () => {
119 const res = await run(event());
120 expect(res).toEqual({ url: "https://stripe.test/cs_1" });
121 const arg = createSession.mock.calls[0][0] as {
122 metadata: Record<string, string>;
123 };
124 expect(arg.metadata).toEqual({
125 device_id: "device-xyz",
126 pack_id: PACK.id,
127 runs: String(PACK.runs),
128 });
129 });
130 
131 it("opens the promotion-code field so buyers can redeem a coupon", async () => {
132 await run(event());
133 const arg = createSession.mock.calls[0][0] as {
134 allow_promotion_codes?: boolean;
135 };
136 expect(arg.allow_promotion_codes).toBe(true);
137 });
⋯ 6 lines hidden (lines 138–143)
138 
139 it("502s when the Stripe session creation fails", async () => {
140 createSession.mockRejectedValue(new Error("stripe down"));
141 await expect(run(event())).rejects.toMatchObject({ statusCode: 502 });
142 });
143});