From c9a8ffd279ab27053258b804c63b3688862b78ba Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 1 May 2026 19:31:29 +0530 Subject: [PATCH 1/2] feat(api): migrate POST /api/stripe/portal/create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/subscriptions/portal-sessions — Stripe billing portal parity for chat's local /api/stripe/portal/create. Derives accountId from validateAuthContext, looks up the Stripe customer via the local billing_customers mirror, returns { id, url }. Pairs with the existing POST /api/subscriptions/sessions for chat's stripe-outbound migration (group 4). --- .../__tests__/route.options.test.ts | 14 +++ .../__tests__/route.post.outcomes.test.ts | 119 ++++++++++++++++++ .../__tests__/route.post.validation.test.ts | 78 ++++++++++++ .../portal-sessions/__tests__/route.test.ts | 11 ++ .../__tests__/routeTestMocks.ts | 21 ++++ .../subscriptions/portal-sessions/route.ts | 30 +++++ lib/stripe/createPortalSession.ts | 12 ++ lib/stripe/createPortalSessionHandler.ts | 41 ++++++ lib/stripe/createPortalSessionSchemas.ts | 7 ++ .../validateCreatePortalSessionRequest.ts | 40 ++++++ .../getStripeCustomerIdByAccountId.ts | 22 ++++ 11 files changed, 395 insertions(+) create mode 100644 app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts create mode 100644 app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts create mode 100644 app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts create mode 100644 app/api/subscriptions/portal-sessions/__tests__/route.test.ts create mode 100644 app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts create mode 100644 app/api/subscriptions/portal-sessions/route.ts create mode 100644 lib/stripe/createPortalSession.ts create mode 100644 lib/stripe/createPortalSessionHandler.ts create mode 100644 lib/stripe/createPortalSessionSchemas.ts create mode 100644 lib/stripe/validateCreatePortalSessionRequest.ts create mode 100644 lib/supabase/billing_customers/getStripeCustomerIdByAccountId.ts diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts b/app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts new file mode 100644 index 000000000..d903f736c --- /dev/null +++ b/app/api/subscriptions/portal-sessions/__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/portal-sessions", () => { + 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/portal-sessions/__tests__/route.post.outcomes.test.ts b/app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts new file mode 100644 index 000000000..dfca500f2 --- /dev/null +++ b/app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts @@ -0,0 +1,119 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreatePortalSessionRequest } from "@/lib/stripe/validateCreatePortalSessionRequest"; +import { createPortalSession } from "@/lib/stripe/createPortalSession"; +import { getStripeCustomerIdByAccountId } from "@/lib/supabase/billing_customers/getStripeCustomerIdByAccountId"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; + +describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreatePortalSessionRequest).mockReset(); + vi.mocked(getStripeCustomerIdByAccountId).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(validateCreatePortalSessionRequest).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + body: "{}", + }); + expect(await POST(req)).toBe(err); + expect(getStripeCustomerIdByAccountId).not.toHaveBeenCalled(); + expect(createPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 404 when account has no Stripe customer", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/back", + }); + vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue(null); + + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + body: "{}", + }), + ); + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ + error: "No Stripe customer found for this account", + }); + expect(createPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 200 with id and url", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/back", + }); + vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123"); + vi.mocked(createPortalSession).mockResolvedValue({ + id: "bps_test_abc", + url: "https://billing.stripe.com/p/session/abc", + } as Awaited>); + + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + body: "{}", + }), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "bps_test_abc", + url: "https://billing.stripe.com/p/session/abc", + }); + expect(createPortalSession).toHaveBeenCalledWith("cus_123", "https://chat.recoupable.com/back"); + }); + + it("returns 400 when session.url is missing", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/back", + }); + vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123"); + vi.mocked(createPortalSession).mockResolvedValue({ + id: "bps_test_abc", + url: null, + } as unknown as Awaited>); + + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + body: "{}", + }), + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Portal session URL missing" }); + }); + + it("returns 500 when createPortalSession throws", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/back", + }); + vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue("cus_123"); + vi.mocked(createPortalSession).mockRejectedValue(new Error("Stripe down")); + + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + body: "{}", + }), + ); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Internal server error" }); + }); +}); diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts b/app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts new file mode 100644 index 000000000..23650cbcd --- /dev/null +++ b/app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts @@ -0,0 +1,78 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreatePortalSessionRequest } from "@/lib/stripe/validateCreatePortalSessionRequest"; +import { createPortalSession } from "@/lib/stripe/createPortalSession"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const { POST } = await import("../route"); + +async function loadRealValidate() { + const mod = await vi.importActual< + typeof import("@/lib/stripe/validateCreatePortalSessionRequest") + >("@/lib/stripe/validateCreatePortalSessionRequest"); + return mod.validateCreatePortalSessionRequest; +} + +describe("POST /api/subscriptions/portal-sessions (validation)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreatePortalSessionRequest).mockReset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(console.error).mockRestore(); + }); + + it("returns 400 when body is invalid JSON", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }), + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid JSON body" }); + expect(createPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when returnUrl is missing", async () => { + vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body).toEqual({ error: expect.stringMatching(/returnUrl|Invalid input/i) }); + expect(createPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 401 when not authenticated", async () => { + vi.mocked(validateAuthContext).mockResolvedValueOnce( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/back" }), + }), + ); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + expect(createPortalSession).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.test.ts b/app/api/subscriptions/portal-sessions/__tests__/route.test.ts new file mode 100644 index 000000000..7e54f26b1 --- /dev/null +++ b/app/api/subscriptions/portal-sessions/__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/portal-sessions/route", () => { + it("exports POST and OPTIONS handlers", () => { + expect(typeof POST).toBe("function"); + expect(typeof OPTIONS).toBe("function"); + }); +}); diff --git a/app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts b/app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..2973d6f8c --- /dev/null +++ b/app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts @@ -0,0 +1,21 @@ +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/validateCreatePortalSessionRequest", () => ({ + validateCreatePortalSessionRequest: vi.fn(), +})); + +vi.mock("@/lib/stripe/createPortalSession", () => ({ + createPortalSession: vi.fn(), +})); + +vi.mock("@/lib/supabase/billing_customers/getStripeCustomerIdByAccountId", () => ({ + getStripeCustomerIdByAccountId: vi.fn(), +})); diff --git a/app/api/subscriptions/portal-sessions/route.ts b/app/api/subscriptions/portal-sessions/route.ts new file mode 100644 index 000000000..05db221b6 --- /dev/null +++ b/app/api/subscriptions/portal-sessions/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createPortalSessionHandler } from "@/lib/stripe/createPortalSessionHandler"; + +/** + * 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/portal-sessions: creates a Stripe billing portal + * session for the authenticated account's existing Stripe customer. + * + * @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 createPortalSessionHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/createPortalSession.ts b/lib/stripe/createPortalSession.ts new file mode 100644 index 000000000..aca856d92 --- /dev/null +++ b/lib/stripe/createPortalSession.ts @@ -0,0 +1,12 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +export async function createPortalSession( + customerId: string, + returnUrl: string, +): Promise { + return stripeClient.billingPortal.sessions.create({ + customer: customerId, + return_url: returnUrl, + }); +} diff --git a/lib/stripe/createPortalSessionHandler.ts b/lib/stripe/createPortalSessionHandler.ts new file mode 100644 index 000000000..38742b38c --- /dev/null +++ b/lib/stripe/createPortalSessionHandler.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createPortalSession } from "@/lib/stripe/createPortalSession"; +import { validateCreatePortalSessionRequest } from "@/lib/stripe/validateCreatePortalSessionRequest"; +import { getStripeCustomerIdByAccountId } from "@/lib/supabase/billing_customers/getStripeCustomerIdByAccountId"; + +export async function createPortalSessionHandler(request: NextRequest): Promise { + try { + const validated = await validateCreatePortalSessionRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const customerId = await getStripeCustomerIdByAccountId(validated.accountId); + if (!customerId) { + return NextResponse.json( + { error: "No Stripe customer found for this account" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const session = await createPortalSession(customerId, validated.returnUrl); + if (!session.url) { + return NextResponse.json( + { error: "Portal session URL missing" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { id: session.id, url: session.url }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[createPortalSessionHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/createPortalSessionSchemas.ts b/lib/stripe/createPortalSessionSchemas.ts new file mode 100644 index 000000000..24ca85982 --- /dev/null +++ b/lib/stripe/createPortalSessionSchemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const createPortalSessionBodySchema = z + .object({ + returnUrl: z.string().min(1, "returnUrl is required").url("returnUrl must be a valid URL"), + }) + .strict(); diff --git a/lib/stripe/validateCreatePortalSessionRequest.ts b/lib/stripe/validateCreatePortalSessionRequest.ts new file mode 100644 index 000000000..778500c2e --- /dev/null +++ b/lib/stripe/validateCreatePortalSessionRequest.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { createPortalSessionBodySchema } from "@/lib/stripe/createPortalSessionSchemas"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +export type ValidatedCreatePortalSessionRequest = { + accountId: string; + returnUrl: string; +}; + +export async function validateCreatePortalSessionRequest( + 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 = createPortalSessionBodySchema.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, + returnUrl: parsed.data.returnUrl, + }; +} diff --git a/lib/supabase/billing_customers/getStripeCustomerIdByAccountId.ts b/lib/supabase/billing_customers/getStripeCustomerIdByAccountId.ts new file mode 100644 index 000000000..1ba3595d8 --- /dev/null +++ b/lib/supabase/billing_customers/getStripeCustomerIdByAccountId.ts @@ -0,0 +1,22 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Look up the Stripe customer ID for an account from the local + * `billing_customers` mirror. Returns `null` when the account has never + * been linked to a Stripe customer. + */ +export async function getStripeCustomerIdByAccountId(accountId: string): Promise { + const { data, error } = await supabase + .from("billing_customers") + .select("customer_id") + .eq("account_id", accountId) + .eq("provider", "stripe") + .maybeSingle(); + + if (error) { + console.error("Error fetching billing_customers:", error); + return null; + } + + return data?.customer_id ?? null; +} From a326a8b9004e8942523132f085d6e19d78125d2d Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 1 May 2026 19:44:15 +0530 Subject: [PATCH 2/2] refactor(api): rename to /api/stripe/{checkout,portal}-sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CHAT_API_MIGRATION_FINDINGS REST cleanup section, the dedicated endpoints land at the REST-aligned paths from day one: /api/subscriptions/sessions -> /api/stripe/checkout-sessions /api/subscriptions/portal-sessions -> /api/stripe/portal-sessions Pure path move — no behavior change. --- .../__tests__/route.options.test.ts | 2 +- .../__tests__/route.post.outcomes.test.ts | 10 +++++----- .../__tests__/route.post.validation.test.ts | 8 ++++---- .../checkout-sessions}/__tests__/route.test.ts | 2 +- .../checkout-sessions}/__tests__/routeTestMocks.ts | 0 .../sessions => stripe/checkout-sessions}/route.ts | 2 +- .../portal-sessions}/__tests__/route.options.test.ts | 2 +- .../__tests__/route.post.outcomes.test.ts | 12 ++++++------ .../__tests__/route.post.validation.test.ts | 8 ++++---- .../portal-sessions}/__tests__/route.test.ts | 2 +- .../portal-sessions/__tests__/routeTestMocks.ts | 0 .../portal-sessions/route.ts | 2 +- .../createSubscriptionSessionHandler.test.ts | 8 ++++---- .../validateCreateSubscriptionSessionRequest.test.ts | 10 +++++----- 14 files changed, 34 insertions(+), 34 deletions(-) rename app/api/{subscriptions/portal-sessions => stripe/checkout-sessions}/__tests__/route.options.test.ts (87%) rename app/api/{subscriptions/sessions => stripe/checkout-sessions}/__tests__/route.post.outcomes.test.ts (88%) rename app/api/{subscriptions/sessions => stripe/checkout-sessions}/__tests__/route.post.validation.test.ts (90%) rename app/api/{subscriptions/portal-sessions => stripe/checkout-sessions}/__tests__/route.test.ts (81%) rename app/api/{subscriptions/sessions => stripe/checkout-sessions}/__tests__/routeTestMocks.ts (100%) rename app/api/{subscriptions/sessions => stripe/checkout-sessions}/route.ts (90%) rename app/api/{subscriptions/sessions => stripe/portal-sessions}/__tests__/route.options.test.ts (88%) rename app/api/{subscriptions => stripe}/portal-sessions/__tests__/route.post.outcomes.test.ts (89%) rename app/api/{subscriptions => stripe}/portal-sessions/__tests__/route.post.validation.test.ts (90%) rename app/api/{subscriptions/sessions => stripe/portal-sessions}/__tests__/route.test.ts (82%) rename app/api/{subscriptions => stripe}/portal-sessions/__tests__/routeTestMocks.ts (100%) rename app/api/{subscriptions => stripe}/portal-sessions/route.ts (91%) diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.options.test.ts similarity index 87% rename from app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts rename to app/api/stripe/checkout-sessions/__tests__/route.options.test.ts index d903f736c..8f59d34fe 100644 --- a/app/api/subscriptions/portal-sessions/__tests__/route.options.test.ts +++ b/app/api/stripe/checkout-sessions/__tests__/route.options.test.ts @@ -4,7 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; const { OPTIONS } = await import("../route"); -describe("OPTIONS /api/subscriptions/portal-sessions", () => { +describe("OPTIONS /api/stripe/checkout-sessions", () => { it("returns 200 with CORS headers", async () => { const res = await OPTIONS(); expect(res.status).toBe(200); diff --git a/app/api/subscriptions/sessions/__tests__/route.post.outcomes.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.post.outcomes.test.ts similarity index 88% rename from app/api/subscriptions/sessions/__tests__/route.post.outcomes.test.ts rename to app/api/stripe/checkout-sessions/__tests__/route.post.outcomes.test.ts index 5c31eaaef..77e256efe 100644 --- a/app/api/subscriptions/sessions/__tests__/route.post.outcomes.test.ts +++ b/app/api/stripe/checkout-sessions/__tests__/route.post.outcomes.test.ts @@ -8,7 +8,7 @@ const { POST } = await import("../route"); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; -describe("POST /api/subscriptions/sessions (handler outcomes)", () => { +describe("POST /api/stripe/checkout-sessions (handler outcomes)", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateCreateSubscriptionSessionRequest).mockReset(); @@ -22,7 +22,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => { it("returns validation response unchanged", async () => { const err = NextResponse.json({ error: "bad" }, { status: 400 }); vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue(err); - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }); @@ -41,7 +41,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => { } as Awaited>); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), @@ -64,7 +64,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => { } as Awaited>); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), @@ -81,7 +81,7 @@ describe("POST /api/subscriptions/sessions (handler outcomes)", () => { vi.mocked(createStripeSession).mockRejectedValue(new Error("Stripe down")); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), diff --git a/app/api/subscriptions/sessions/__tests__/route.post.validation.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.post.validation.test.ts similarity index 90% rename from app/api/subscriptions/sessions/__tests__/route.post.validation.test.ts rename to app/api/stripe/checkout-sessions/__tests__/route.post.validation.test.ts index 6c5056bfc..bb2f616e4 100644 --- a/app/api/subscriptions/sessions/__tests__/route.post.validation.test.ts +++ b/app/api/stripe/checkout-sessions/__tests__/route.post.validation.test.ts @@ -14,7 +14,7 @@ async function loadRealValidate() { return mod.validateCreateSubscriptionSessionRequest; } -describe("POST /api/subscriptions/sessions (validation)", () => { +describe("POST /api/stripe/checkout-sessions (validation)", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateCreateSubscriptionSessionRequest).mockReset(); @@ -30,7 +30,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => { await loadRealValidate(), ); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: "not-json", @@ -46,7 +46,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => { await loadRealValidate(), ); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), @@ -69,7 +69,7 @@ describe("POST /api/subscriptions/sessions (validation)", () => { await loadRealValidate(), ); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ successUrl: "https://chat.recoupable.com/ok" }), diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.test.ts similarity index 81% rename from app/api/subscriptions/portal-sessions/__tests__/route.test.ts rename to app/api/stripe/checkout-sessions/__tests__/route.test.ts index 7e54f26b1..dc946f5e4 100644 --- a/app/api/subscriptions/portal-sessions/__tests__/route.test.ts +++ b/app/api/stripe/checkout-sessions/__tests__/route.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; const { POST, OPTIONS } = await import("../route"); -describe("app/api/subscriptions/portal-sessions/route", () => { +describe("app/api/stripe/checkout-sessions/route", () => { it("exports POST and OPTIONS handlers", () => { expect(typeof POST).toBe("function"); expect(typeof OPTIONS).toBe("function"); diff --git a/app/api/subscriptions/sessions/__tests__/routeTestMocks.ts b/app/api/stripe/checkout-sessions/__tests__/routeTestMocks.ts similarity index 100% rename from app/api/subscriptions/sessions/__tests__/routeTestMocks.ts rename to app/api/stripe/checkout-sessions/__tests__/routeTestMocks.ts diff --git a/app/api/subscriptions/sessions/route.ts b/app/api/stripe/checkout-sessions/route.ts similarity index 90% rename from app/api/subscriptions/sessions/route.ts rename to app/api/stripe/checkout-sessions/route.ts index c6b65b0cb..a3fb2a691 100644 --- a/app/api/subscriptions/sessions/route.ts +++ b/app/api/stripe/checkout-sessions/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * POST /api/subscriptions/sessions: creates a Stripe subscription checkout session. + * POST /api/stripe/checkout-sessions: creates a Stripe subscription checkout session. * * @param request - The incoming HTTP request. * @returns A NextResponse with session `id` and `url`, or an error body. diff --git a/app/api/subscriptions/sessions/__tests__/route.options.test.ts b/app/api/stripe/portal-sessions/__tests__/route.options.test.ts similarity index 88% rename from app/api/subscriptions/sessions/__tests__/route.options.test.ts rename to app/api/stripe/portal-sessions/__tests__/route.options.test.ts index bf7dc24dd..1e94ca3a6 100644 --- a/app/api/subscriptions/sessions/__tests__/route.options.test.ts +++ b/app/api/stripe/portal-sessions/__tests__/route.options.test.ts @@ -4,7 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; const { OPTIONS } = await import("../route"); -describe("OPTIONS /api/subscriptions/sessions", () => { +describe("OPTIONS /api/stripe/portal-sessions", () => { it("returns 200 with CORS headers", async () => { const res = await OPTIONS(); expect(res.status).toBe(200); diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts b/app/api/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts similarity index 89% rename from app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts rename to app/api/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts index dfca500f2..524a0daac 100644 --- a/app/api/subscriptions/portal-sessions/__tests__/route.post.outcomes.test.ts +++ b/app/api/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts @@ -9,7 +9,7 @@ const { POST } = await import("../route"); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; -describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { +describe("POST /api/stripe/portal-sessions (handler outcomes)", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateCreatePortalSessionRequest).mockReset(); @@ -24,7 +24,7 @@ describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { it("returns validation response unchanged", async () => { const err = NextResponse.json({ error: "bad" }, { status: 400 }); vi.mocked(validateCreatePortalSessionRequest).mockResolvedValue(err); - const req = new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + const req = new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", body: "{}", }); @@ -41,7 +41,7 @@ describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { vi.mocked(getStripeCustomerIdByAccountId).mockResolvedValue(null); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", body: "{}", }), @@ -65,7 +65,7 @@ describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { } as Awaited>); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", body: "{}", }), @@ -90,7 +90,7 @@ describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { } as unknown as Awaited>); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", body: "{}", }), @@ -108,7 +108,7 @@ describe("POST /api/subscriptions/portal-sessions (handler outcomes)", () => { vi.mocked(createPortalSession).mockRejectedValue(new Error("Stripe down")); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", body: "{}", }), diff --git a/app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts b/app/api/stripe/portal-sessions/__tests__/route.post.validation.test.ts similarity index 90% rename from app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts rename to app/api/stripe/portal-sessions/__tests__/route.post.validation.test.ts index 23650cbcd..d6bfab0b7 100644 --- a/app/api/subscriptions/portal-sessions/__tests__/route.post.validation.test.ts +++ b/app/api/stripe/portal-sessions/__tests__/route.post.validation.test.ts @@ -14,7 +14,7 @@ async function loadRealValidate() { return mod.validateCreatePortalSessionRequest; } -describe("POST /api/subscriptions/portal-sessions (validation)", () => { +describe("POST /api/stripe/portal-sessions (validation)", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(validateCreatePortalSessionRequest).mockReset(); @@ -28,7 +28,7 @@ describe("POST /api/subscriptions/portal-sessions (validation)", () => { it("returns 400 when body is invalid JSON", async () => { vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: "not-json", @@ -42,7 +42,7 @@ describe("POST /api/subscriptions/portal-sessions (validation)", () => { it("returns 400 when returnUrl is missing", async () => { vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), @@ -63,7 +63,7 @@ describe("POST /api/subscriptions/portal-sessions (validation)", () => { ); vi.mocked(validateCreatePortalSessionRequest).mockImplementationOnce(await loadRealValidate()); const res = await POST( - new NextRequest("http://localhost/api/subscriptions/portal-sessions", { + new NextRequest("http://localhost/api/stripe/portal-sessions", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/back" }), diff --git a/app/api/subscriptions/sessions/__tests__/route.test.ts b/app/api/stripe/portal-sessions/__tests__/route.test.ts similarity index 82% rename from app/api/subscriptions/sessions/__tests__/route.test.ts rename to app/api/stripe/portal-sessions/__tests__/route.test.ts index 9d53c81c7..c56ac2896 100644 --- a/app/api/subscriptions/sessions/__tests__/route.test.ts +++ b/app/api/stripe/portal-sessions/__tests__/route.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest"; const { POST, OPTIONS } = await import("../route"); -describe("app/api/subscriptions/sessions/route", () => { +describe("app/api/stripe/portal-sessions/route", () => { it("exports POST and OPTIONS handlers", () => { expect(typeof POST).toBe("function"); expect(typeof OPTIONS).toBe("function"); diff --git a/app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts b/app/api/stripe/portal-sessions/__tests__/routeTestMocks.ts similarity index 100% rename from app/api/subscriptions/portal-sessions/__tests__/routeTestMocks.ts rename to app/api/stripe/portal-sessions/__tests__/routeTestMocks.ts diff --git a/app/api/subscriptions/portal-sessions/route.ts b/app/api/stripe/portal-sessions/route.ts similarity index 91% rename from app/api/subscriptions/portal-sessions/route.ts rename to app/api/stripe/portal-sessions/route.ts index 05db221b6..5f9e6ecdf 100644 --- a/app/api/subscriptions/portal-sessions/route.ts +++ b/app/api/stripe/portal-sessions/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * POST /api/subscriptions/portal-sessions: creates a Stripe billing portal + * POST /api/stripe/portal-sessions: creates a Stripe billing portal * session for the authenticated account's existing Stripe customer. * * @param request - The incoming HTTP request. diff --git a/lib/stripe/__tests__/createSubscriptionSessionHandler.test.ts b/lib/stripe/__tests__/createSubscriptionSessionHandler.test.ts index 19e7b27dd..64ea7665c 100644 --- a/lib/stripe/__tests__/createSubscriptionSessionHandler.test.ts +++ b/lib/stripe/__tests__/createSubscriptionSessionHandler.test.ts @@ -28,7 +28,7 @@ describe("createSubscriptionSessionHandler", () => { it("returns validation response unchanged", async () => { const err = NextResponse.json({ error: "bad" }, { status: 400 }); vi.mocked(validateCreateSubscriptionSessionRequest).mockResolvedValue(err); - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }); @@ -47,7 +47,7 @@ describe("createSubscriptionSessionHandler", () => { } as Awaited>); const res = await createSubscriptionSessionHandler( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), @@ -70,7 +70,7 @@ describe("createSubscriptionSessionHandler", () => { } as Awaited>); const res = await createSubscriptionSessionHandler( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), @@ -87,7 +87,7 @@ describe("createSubscriptionSessionHandler", () => { vi.mocked(createStripeSession).mockRejectedValue(new Error("Stripe down")); const res = await createSubscriptionSessionHandler( - new NextRequest("http://localhost/api/subscriptions/sessions", { + new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", body: "{}", }), diff --git a/lib/stripe/__tests__/validateCreateSubscriptionSessionRequest.test.ts b/lib/stripe/__tests__/validateCreateSubscriptionSessionRequest.test.ts index bfbc308b2..a3173fc39 100644 --- a/lib/stripe/__tests__/validateCreateSubscriptionSessionRequest.test.ts +++ b/lib/stripe/__tests__/validateCreateSubscriptionSessionRequest.test.ts @@ -19,7 +19,7 @@ describe("validateCreateSubscriptionSessionRequest", () => { }); it("returns 400 { error } for invalid JSON", async () => { - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "k" }, body: "not-json", @@ -32,7 +32,7 @@ describe("validateCreateSubscriptionSessionRequest", () => { }); it("returns 400 { error } when successUrl is missing", async () => { - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "k" }, body: JSON.stringify({}), @@ -44,7 +44,7 @@ describe("validateCreateSubscriptionSessionRequest", () => { }); it("returns 400 { error } for unknown body keys (strict)", async () => { - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "k" }, body: JSON.stringify({ @@ -63,7 +63,7 @@ describe("validateCreateSubscriptionSessionRequest", () => { { status: 401 }, ), ); - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ successUrl: "https://chat.recoupable.com/done" }), @@ -81,7 +81,7 @@ describe("validateCreateSubscriptionSessionRequest", () => { orgId: null, authToken: "t", }); - const req = new NextRequest("http://localhost/api/subscriptions/sessions", { + const req = new NextRequest("http://localhost/api/stripe/checkout-sessions", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "k" }, body: JSON.stringify({