From 35f8a5b9b8c7b97a37bd8d50752d2123674471be Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 17:24:32 -0500 Subject: [PATCH 1/3] feat(subscriptions): POST /api/subscriptions/card-on-file ($0 card registration) Exposes the existing createCardOnFileSession helper over HTTP so a browser can start a Stripe `mode: "setup"` checkout that saves a card for the authenticated account with no charge and no subscription. Until now the helper had no route: its only caller was server-internal (ensureSongstatsPaymentMethod), so nothing a browser could hit. Mirrors POST /api/subscriptions/sessions layer for layer (route -> handler -> validate), reusing its strict `{ successUrl }` body schema, its auth-error mapper, and its flat `{ id, url }` envelope. - app/api/subscriptions/card-on-file/route.ts - POST plus the CORS OPTIONS handler so recoupable.dev can call it cross-origin - lib/stripe/createCardOnFileSessionHandler.ts - validate, mint, 400 on a missing session URL, 500 on a Stripe failure - lib/stripe/validateCreateCardOnFileSessionRequest.ts - zod body check then validateAuthContext; the account always comes from the credentials and an `accountId` in the body 400s (strict schema) 13 tests: happy path, 400 invalid JSON / missing successUrl / non-URL successUrl / caller-supplied accountId, 401 with no credentials, 401 with an invalid bearer, 500 on a Stripe throw, and OPTIONS. Part of recoupable/chat#1910 Co-Authored-By: Claude Fable 5 --- .../__tests__/route.options.test.ts | 14 ++ .../__tests__/route.post.outcomes.test.ts | 82 ++++++++++++ .../__tests__/route.post.validation.test.ts | 121 ++++++++++++++++++ .../card-on-file/__tests__/route.test.ts | 11 ++ .../card-on-file/__tests__/routeTestMocks.ts | 17 +++ app/api/subscriptions/card-on-file/route.ts | 32 +++++ lib/stripe/createCardOnFileSessionHandler.ts | 39 ++++++ .../validateCreateCardOnFileSessionRequest.ts | 50 ++++++++ 8 files changed, 366 insertions(+) create mode 100644 app/api/subscriptions/card-on-file/__tests__/route.options.test.ts create mode 100644 app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts create mode 100644 app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts create mode 100644 app/api/subscriptions/card-on-file/__tests__/route.test.ts create mode 100644 app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts create mode 100644 app/api/subscriptions/card-on-file/route.ts create mode 100644 lib/stripe/createCardOnFileSessionHandler.ts create mode 100644 lib/stripe/validateCreateCardOnFileSessionRequest.ts diff --git a/app/api/subscriptions/card-on-file/__tests__/route.options.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.options.test.ts new file mode 100644 index 000000000..b35a6a371 --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/route.options.test.ts @@ -0,0 +1,14 @@ +import "./routeTestMocks"; +import { describe, it, expect } from "vitest"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +const { OPTIONS } = await import("../route"); + +describe("OPTIONS /api/subscriptions/card-on-file", () => { + it("returns 200 with CORS headers", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + expect(getCorsHeaders).toHaveBeenCalled(); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts new file mode 100644 index 000000000..e0a364c22 --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts @@ -0,0 +1,82 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateCardOnFileSessionRequest } from "@/lib/stripe/validateCreateCardOnFileSessionRequest"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; +const SUCCESS_URL = "https://recoupable.dev/card-saved"; + +function postRequest(): NextRequest { + return new NextRequest("http://localhost/api/subscriptions/card-on-file", { + method: "POST", + body: "{}", + }); +} + +describe("POST /api/subscriptions/card-on-file (handler outcomes)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateCardOnFileSessionRequest).mockReset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(console.error).mockRestore(); + }); + + it("returns validation response unchanged", async () => { + const err = NextResponse.json({ error: "bad" }, { status: 400 }); + vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue(err); + expect(await POST(postRequest())).toBe(err); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 200 with id and url for the authenticated account", async () => { + vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + successUrl: SUCCESS_URL, + }); + vi.mocked(createCardOnFileSession).mockResolvedValue({ + id: "cs_test_setup", + url: "https://checkout.stripe.com/pay/cs_test_setup", + } as Awaited>); + + const res = await POST(postRequest()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "cs_test_setup", + url: "https://checkout.stripe.com/pay/cs_test_setup", + }); + expect(createCardOnFileSession).toHaveBeenCalledWith(ACCOUNT, SUCCESS_URL); + }); + + it("returns 400 when session.url is null", async () => { + vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + successUrl: SUCCESS_URL, + }); + vi.mocked(createCardOnFileSession).mockResolvedValue({ + id: "cs_test_setup", + url: null, + } as Awaited>); + + const res = await POST(postRequest()); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Checkout session URL missing" }); + }); + + it("returns 500 when createCardOnFileSession throws", async () => { + vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + successUrl: SUCCESS_URL, + }); + vi.mocked(createCardOnFileSession).mockRejectedValue(new Error("Stripe down")); + + const res = await POST(postRequest()); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Internal server error" }); + }); +}); diff --git a/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts new file mode 100644 index 000000000..02bd3f90e --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts @@ -0,0 +1,121 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateCardOnFileSessionRequest } from "@/lib/stripe/validateCreateCardOnFileSessionRequest"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; +const SUCCESS_URL = "https://recoupable.dev/card-saved"; + +async function loadRealValidate() { + const mod = await vi.importActual< + typeof import("@/lib/stripe/validateCreateCardOnFileSessionRequest") + >("@/lib/stripe/validateCreateCardOnFileSessionRequest"); + return mod.validateCreateCardOnFileSessionRequest; +} + +function postRequest(body: string, headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/subscriptions/card-on-file", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }); +} + +describe("POST /api/subscriptions/card-on-file (validation)", () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(validateCreateCardOnFileSessionRequest).mockReset(); + vi.mocked(validateCreateCardOnFileSessionRequest).mockImplementation(await loadRealValidate()); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(console.error).mockRestore(); + }); + + it("returns 400 when body is invalid JSON", async () => { + const res = await POST(postRequest("not-json")); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid JSON body" }); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when successUrl is missing", async () => { + const res = await POST(postRequest(JSON.stringify({}))); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: expect.stringMatching(/successUrl|Invalid input/i), + }); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when successUrl is not a URL", async () => { + const res = await POST(postRequest(JSON.stringify({ successUrl: "not-a-url" }))); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ + error: expect.stringMatching(/successUrl/i), + }); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 400 and never trusts an accountId supplied by the caller", async () => { + const res = await POST( + postRequest(JSON.stringify({ successUrl: SUCCESS_URL, accountId: ACCOUNT })), + ); + expect(res.status).toBe(400); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 401 when no credentials are provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValueOnce( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const res = await POST(postRequest(JSON.stringify({ successUrl: SUCCESS_URL }))); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("returns 401 when the bearer token is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValueOnce( + NextResponse.json({ status: "error", error: "Invalid access token" }, { status: 401 }), + ); + const res = await POST( + postRequest(JSON.stringify({ successUrl: SUCCESS_URL }), { + authorization: "Bearer not-a-real-privy-token", + }), + ); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ error: "Invalid access token" }); + expect(createCardOnFileSession).not.toHaveBeenCalled(); + }); + + it("resolves the account from the credentials on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValueOnce({ accountId: ACCOUNT } as Awaited< + ReturnType + >); + vi.mocked(createCardOnFileSession).mockResolvedValue({ + id: "cs_test_setup", + url: "https://checkout.stripe.com/pay/cs_test_setup", + } as Awaited>); + + const res = await POST( + postRequest(JSON.stringify({ successUrl: SUCCESS_URL }), { "x-api-key": "k" }), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "cs_test_setup", + url: "https://checkout.stripe.com/pay/cs_test_setup", + }); + expect(createCardOnFileSession).toHaveBeenCalledWith(ACCOUNT, SUCCESS_URL); + }); +}); diff --git a/app/api/subscriptions/card-on-file/__tests__/route.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.test.ts new file mode 100644 index 000000000..df5f82a8e --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/route.test.ts @@ -0,0 +1,11 @@ +import "./routeTestMocks"; +import { describe, it, expect } from "vitest"; + +const { POST, OPTIONS } = await import("../route"); + +describe("app/api/subscriptions/card-on-file/route", () => { + it("exports POST and OPTIONS handlers", () => { + expect(typeof POST).toBe("function"); + expect(typeof OPTIONS).toBe("function"); + }); +}); diff --git a/app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts b/app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..f2d5356bf --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts @@ -0,0 +1,17 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateCreateCardOnFileSessionRequest", () => ({ + validateCreateCardOnFileSessionRequest: vi.fn(), +})); + +vi.mock("@/lib/stripe/createCardOnFileSession", () => ({ + createCardOnFileSession: vi.fn(), +})); diff --git a/app/api/subscriptions/card-on-file/route.ts b/app/api/subscriptions/card-on-file/route.ts new file mode 100644 index 000000000..91dc0e158 --- /dev/null +++ b/app/api/subscriptions/card-on-file/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createCardOnFileSessionHandler } from "@/lib/stripe/createCardOnFileSessionHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/subscriptions/card-on-file: creates a $0 Stripe `setup` checkout + * session that saves a card on file for the authenticated account. No charge is + * made and no subscription starts; the saved card is what lets a later credit + * shortfall auto-recharge instead of dead-ending. + * + * @param request - The incoming HTTP request. + * @returns A NextResponse with session `id` and `url`, or an error body. + */ +export async function POST(request: NextRequest) { + return createCardOnFileSessionHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/createCardOnFileSessionHandler.ts b/lib/stripe/createCardOnFileSessionHandler.ts new file mode 100644 index 000000000..9e800d8f5 --- /dev/null +++ b/lib/stripe/createCardOnFileSessionHandler.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createCardOnFileSession } from "@/lib/stripe/createCardOnFileSession"; +import { validateCreateCardOnFileSessionRequest } from "@/lib/stripe/validateCreateCardOnFileSessionRequest"; + +/** + * Handle a card-on-file session request: validate, then mint a $0 Stripe + * `setup` session that saves a card for the authenticated account. + * + * @param request - The incoming HTTP request. + * @returns A NextResponse with session `id` and `url`, or an error body. + */ +export async function createCardOnFileSessionHandler(request: NextRequest): Promise { + try { + const validated = await validateCreateCardOnFileSessionRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const session = await createCardOnFileSession(validated.accountId, validated.successUrl); + if (!session.url) { + return NextResponse.json( + { error: "Checkout session URL missing" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { id: session.id, url: session.url }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[createCardOnFileSessionHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/validateCreateCardOnFileSessionRequest.ts b/lib/stripe/validateCreateCardOnFileSessionRequest.ts new file mode 100644 index 000000000..e09421d19 --- /dev/null +++ b/lib/stripe/validateCreateCardOnFileSessionRequest.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +// The card-on-file body is the same single `successUrl` contract as the paid +// subscription session, so the strict schema is shared rather than duplicated. +import { createSubscriptionSessionBodySchema } from "@/lib/stripe/createSubscriptionSessionSchemas"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +export type ValidatedCreateCardOnFileSessionRequest = { + accountId: string; + successUrl: string; +}; + +/** + * Validate a card-on-file session request: the body must be `{ successUrl }` + * and the account is always resolved from the credentials, never read from the + * caller-supplied body (the schema is strict, so an `accountId` key 400s). + * + * @param request - The incoming HTTP request. + * @returns The validated account id and success URL, or an error response. + */ +export async function validateCreateCardOnFileSessionRequest( + request: NextRequest, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsed = createSubscriptionSessionBodySchema.safeParse(body); + if (!parsed.success) { + const first = parsed.error.issues[0]; + return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); + } + + const authContext = await validateAuthContext(request, {}); + if (authContext instanceof NextResponse) { + return mapToSubscriptionSessionError(authContext); + } + + return { + accountId: authContext.accountId, + successUrl: parsed.data.successUrl, + }; +} From 3a089b93e302727afd886fefab481df25589c4b0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 17:30:48 -0500 Subject: [PATCH 2/3] fix(subscriptions): 500 when Stripe returns a session without a URL A card-on-file request that already passed validation and auth cannot be corrected by the caller, so a session missing its URL is an upstream failure, not a bad request. Deliberate divergence from the sibling subscription route, which returns 400 for the same condition. Co-Authored-By: Claude Fable 5 --- .../card-on-file/__tests__/route.post.outcomes.test.ts | 4 ++-- lib/stripe/createCardOnFileSessionHandler.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts index e0a364c22..3ae5e07c9 100644 --- a/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts +++ b/app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts @@ -53,7 +53,7 @@ describe("POST /api/subscriptions/card-on-file (handler outcomes)", () => { expect(createCardOnFileSession).toHaveBeenCalledWith(ACCOUNT, SUCCESS_URL); }); - it("returns 400 when session.url is null", async () => { + it("returns 500 when session.url is null", async () => { vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({ accountId: ACCOUNT, successUrl: SUCCESS_URL, @@ -64,7 +64,7 @@ describe("POST /api/subscriptions/card-on-file (handler outcomes)", () => { } as Awaited>); const res = await POST(postRequest()); - expect(res.status).toBe(400); + expect(res.status).toBe(500); await expect(res.json()).resolves.toEqual({ error: "Checkout session URL missing" }); }); diff --git a/lib/stripe/createCardOnFileSessionHandler.ts b/lib/stripe/createCardOnFileSessionHandler.ts index 9e800d8f5..d4059b7cc 100644 --- a/lib/stripe/createCardOnFileSessionHandler.ts +++ b/lib/stripe/createCardOnFileSessionHandler.ts @@ -18,10 +18,12 @@ export async function createCardOnFileSessionHandler(request: NextRequest): Prom } const session = await createCardOnFileSession(validated.accountId, validated.successUrl); + // The caller cannot correct a session Stripe returned without a URL, so + // this is a 500 rather than the sibling subscription route's 400. if (!session.url) { return NextResponse.json( { error: "Checkout session URL missing" }, - { status: 400, headers: getCorsHeaders() }, + { status: 500, headers: getCorsHeaders() }, ); } From c11e4c796063caab48b5a6d65605e8d9621a04ce Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 18:15:11 -0500 Subject: [PATCH 3/3] refactor: parse the card-on-file body with the shared safeParseJson Review feedback: drop the hand-rolled try/catch around request.json() in favor of lib/networking/safeParseJson, the shared helper used by 33 other validators. safeParseJson returns {} for an absent or unparseable body, so an unparseable body now surfaces the same missing-field 400 as an empty one instead of a JSON-specific message. Still 400 either way, so the documented contract holds; the validation test was updated to the new message. Co-Authored-By: Claude Fable 5 --- .../__tests__/route.post.validation.test.ts | 6 +++++- .../validateCreateCardOnFileSessionRequest.ts | 15 ++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts b/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts index 02bd3f90e..33a26ca83 100644 --- a/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts +++ b/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts @@ -40,7 +40,11 @@ describe("POST /api/subscriptions/card-on-file (validation)", () => { it("returns 400 when body is invalid JSON", async () => { const res = await POST(postRequest("not-json")); expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ error: "Invalid JSON body" }); + // safeParseJson turns an unparseable body into `{}`, so this lands on the + // same missing-field message as an empty body rather than a JSON-specific one. + await expect(res.json()).resolves.toEqual({ + error: "Invalid input: expected string, received undefined", + }); expect(createCardOnFileSession).not.toHaveBeenCalled(); }); diff --git a/lib/stripe/validateCreateCardOnFileSessionRequest.ts b/lib/stripe/validateCreateCardOnFileSessionRequest.ts index e09421d19..286cf5d1e 100644 --- a/lib/stripe/validateCreateCardOnFileSessionRequest.ts +++ b/lib/stripe/validateCreateCardOnFileSessionRequest.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; // The card-on-file body is the same single `successUrl` contract as the paid // subscription session, so the strict schema is shared rather than duplicated. @@ -22,17 +23,9 @@ export type ValidatedCreateCardOnFileSessionRequest = { export async function validateCreateCardOnFileSessionRequest( request: NextRequest, ): Promise { - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { error: "Invalid JSON body" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const parsed = createSubscriptionSessionBodySchema.safeParse(body); + // safeParseJson yields `{}` for an absent or unparseable body, so both fall + // through to the schema and surface as the same 400 as a missing field. + const parsed = createSubscriptionSessionBodySchema.safeParse(await safeParseJson(request)); if (!parsed.success) { const first = parsed.error.issues[0]; return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() });