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..3ae5e07c9 --- /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 500 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(500); + 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..33a26ca83 --- /dev/null +++ b/app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts @@ -0,0 +1,125 @@ +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); + // 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(); + }); + + 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..d4059b7cc --- /dev/null +++ b/lib/stripe/createCardOnFileSessionHandler.ts @@ -0,0 +1,41 @@ +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); + // 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: 500, 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..286cf5d1e --- /dev/null +++ b/lib/stripe/validateCreateCardOnFileSessionRequest.ts @@ -0,0 +1,43 @@ +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. +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 { + // 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() }); + } + + const authContext = await validateAuthContext(request, {}); + if (authContext instanceof NextResponse) { + return mapToSubscriptionSessionError(authContext); + } + + return { + accountId: authContext.accountId, + successUrl: parsed.data.successUrl, + }; +}