diff --git a/app/api/stripe/checkout-sessions/__tests__/route.options.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.options.test.ts new file mode 100644 index 000000000..8f59d34fe --- /dev/null +++ b/app/api/stripe/checkout-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/stripe/checkout-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/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/stripe/checkout-sessions/__tests__/route.test.ts b/app/api/stripe/checkout-sessions/__tests__/route.test.ts new file mode 100644 index 000000000..dc946f5e4 --- /dev/null +++ b/app/api/stripe/checkout-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/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/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts b/app/api/stripe/portal-sessions/__tests__/route.post.outcomes.test.ts new file mode 100644 index 000000000..524a0daac --- /dev/null +++ b/app/api/stripe/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/stripe/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/stripe/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/stripe/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/stripe/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/stripe/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/stripe/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/stripe/portal-sessions/__tests__/route.post.validation.test.ts b/app/api/stripe/portal-sessions/__tests__/route.post.validation.test.ts new file mode 100644 index 000000000..d6bfab0b7 --- /dev/null +++ b/app/api/stripe/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/stripe/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/stripe/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/stripe/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/stripe/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/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/stripe/portal-sessions/__tests__/routeTestMocks.ts b/app/api/stripe/portal-sessions/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..2973d6f8c --- /dev/null +++ b/app/api/stripe/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/stripe/portal-sessions/route.ts b/app/api/stripe/portal-sessions/route.ts new file mode 100644 index 000000000..5f9e6ecdf --- /dev/null +++ b/app/api/stripe/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/stripe/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/__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({ 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; +}