Skip to content
Merged
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
14 changes: 14 additions & 0 deletions app/api/subscriptions/card-on-file/__tests__/route.options.test.ts
Original file line number Diff line number Diff line change
@@ -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("*");
});
});
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createCardOnFileSession>>);

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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

The PR description claims a 400 error for 'session without a URL', but the handler (createCardOnFileSessionHandler.ts) returns 500 for that case with a deliberate comment explaining the choice: 'The caller cannot correct a session Stripe returned without a URL, so this is a 500 rather than the sibling subscription route's 400.' Update the PR description to say 500 instead of 400 for the null-session-URL error case so it accurately reflects the implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts, line 56:

<comment>The PR description claims a 400 error for 'session without a URL', but the handler (`createCardOnFileSessionHandler.ts`) returns 500 for that case with a deliberate comment explaining the choice: 'The caller cannot correct a session Stripe returned without a URL, so this is a 500 rather than the sibling subscription route's 400.' Update the PR description to say 500 instead of 400 for the null-session-URL error case so it accurately reflects the implementation.</comment>

<file context>
@@ -53,7 +53,7 @@ describe("POST /api/subscriptions/card-on-file (handler outcomes)", () => {
   });
 
-  it("returns 400 when session.url is null", async () => {
+  it("returns 500 when session.url is null", async () => {
     vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({
       accountId: ACCOUNT,
</file context>

vi.mocked(validateCreateCardOnFileSessionRequest).mockResolvedValue({
accountId: ACCOUNT,
successUrl: SUCCESS_URL,
});
vi.mocked(createCardOnFileSession).mockResolvedValue({
id: "cs_test_setup",
url: null,
} as Awaited<ReturnType<typeof createCardOnFileSession>>);

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" });
});
});
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): 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<typeof validateAuthContext>
>);
vi.mocked(createCardOnFileSession).mockResolvedValue({
id: "cs_test_setup",
url: "https://checkout.stripe.com/pay/cs_test_setup",
} as Awaited<ReturnType<typeof createCardOnFileSession>>);

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);
});
});
11 changes: 11 additions & 0 deletions app/api/subscriptions/card-on-file/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
17 changes: 17 additions & 0 deletions app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts
Original file line number Diff line number Diff line change
@@ -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(),
}));
32 changes: 32 additions & 0 deletions app/api/subscriptions/card-on-file/route.ts
Original file line number Diff line number Diff line change
@@ -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;
41 changes: 41 additions & 0 deletions lib/stripe/createCardOnFileSessionHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Checkout-session response handling now exists in two near-identical handlers, so changes to error mapping, CORS, or response shape can silently diverge. Extract the shared validate/create/respond flow and supply the card-on-file-specific validator and session factory.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/stripe/createCardOnFileSessionHandler.ts, line 13:

<comment>Checkout-session response handling now exists in two near-identical handlers, so changes to error mapping, CORS, or response shape can silently diverge. Extract the shared validate/create/respond flow and supply the card-on-file-specific validator and session factory.</comment>

<file context>
@@ -0,0 +1,39 @@
+ * @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<NextResponse> {
+  try {
+    const validated = await validateCreateCardOnFileSessionRequest(request);
</file context>

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() },
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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() },
);
}
}
43 changes: 43 additions & 0 deletions lib/stripe/validateCreateCardOnFileSessionRequest.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | ValidatedCreateCardOnFileSessionRequest> {
// 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,
};
}
Loading