-
Notifications
You must be signed in to change notification settings - Fork 10
feat(subscriptions): POST /api/subscriptions/card-on-file ($0 card registration) #797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
35f8a5b
feat(subscriptions): POST /api/subscriptions/card-on-file ($0 card re…
sweetmantech 3a089b9
fix(subscriptions): 500 when Stripe returns a session without a URL
sweetmantech c11e4c7
refactor: parse the card-on-file body with the shared safeParseJson
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
14 changes: 14 additions & 0 deletions
14
app/api/subscriptions/card-on-file/__tests__/route.options.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("*"); | ||
| }); | ||
| }); |
82 changes: 82 additions & 0 deletions
82
app/api/subscriptions/card-on-file/__tests__/route.post.outcomes.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 () => { | ||
| 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" }); | ||
| }); | ||
| }); | ||
125 changes: 125 additions & 0 deletions
125
app/api/subscriptions/card-on-file/__tests__/route.post.validation.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
11
app/api/subscriptions/card-on-file/__tests__/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
17
app/api/subscriptions/card-on-file/__tests__/routeTestMocks.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| })); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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() }, | ||
| ); | ||
|
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() }, | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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