diff --git a/app/api/subscriptions/portal/__tests__/route.options.test.ts b/app/api/subscriptions/portal/__tests__/route.options.test.ts new file mode 100644 index 000000000..38b2bfdb7 --- /dev/null +++ b/app/api/subscriptions/portal/__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", () => { + 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/__tests__/route.post.outcomes.early.test.ts b/app/api/subscriptions/portal/__tests__/route.post.outcomes.early.test.ts new file mode 100644 index 000000000..c3b101998 --- /dev/null +++ b/app/api/subscriptions/portal/__tests__/route.post.outcomes.early.test.ts @@ -0,0 +1,58 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; + +describe("POST /api/subscriptions/portal (handler outcomes — validation & no subscription)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateSubscriptionPortalBody).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(validateCreateSubscriptionPortalBody).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + body: "{}", + }); + expect(await POST(req)).toBe(err); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns 400 when no active subscription", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }), + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "No active subscription found" }); + expect(createBillingPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 500 when subscription lookup fails", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockRejectedValue(new Error("stripe down")); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { 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/__tests__/route.post.outcomes.portal.errors.test.ts b/app/api/subscriptions/portal/__tests__/route.post.outcomes.portal.errors.test.ts new file mode 100644 index 000000000..db6db0b6d --- /dev/null +++ b/app/api/subscriptions/portal/__tests__/route.post.outcomes.portal.errors.test.ts @@ -0,0 +1,53 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; + +function mockValidated() { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + customer: "cus_test_123", + } as Awaited>); +} + +describe("POST /api/subscriptions/portal (portal session errors)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateSubscriptionPortalBody).mockReset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns 400 when session.url is null", async () => { + mockValidated(); + vi.mocked(createBillingPortalSession).mockResolvedValue({ + id: "bps_test_abc", + url: null, + } as Awaited>); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }), + ); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Billing portal URL missing" }); + }); + + it("returns 500 when createBillingPortalSession throws", async () => { + mockValidated(); + vi.mocked(createBillingPortalSession).mockRejectedValue(new Error("Stripe down")); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { 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/__tests__/route.post.outcomes.portal.success.test.ts b/app/api/subscriptions/portal/__tests__/route.post.outcomes.portal.success.test.ts new file mode 100644 index 000000000..f225a9a15 --- /dev/null +++ b/app/api/subscriptions/portal/__tests__/route.post.outcomes.portal.success.test.ts @@ -0,0 +1,42 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +const { POST } = await import("../route"); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174001"; + +describe("POST /api/subscriptions/portal (200)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateSubscriptionPortalBody).mockReset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns id and url when portal session is created", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + customer: "cus_test_123", + } as Awaited>); + vi.mocked(createBillingPortalSession).mockResolvedValue({ + id: "bps_test_abc", + url: "https://billing.example.com/session/abc", + } as Awaited>); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "bps_test_abc", + url: "https://billing.example.com/session/abc", + }); + }); +}); diff --git a/app/api/subscriptions/portal/__tests__/route.post.validation.test.ts b/app/api/subscriptions/portal/__tests__/route.post.validation.test.ts new file mode 100644 index 000000000..1238b6490 --- /dev/null +++ b/app/api/subscriptions/portal/__tests__/route.post.validation.test.ts @@ -0,0 +1,84 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const { POST } = await import("../route"); + +async function loadRealValidate() { + const mod = await vi.importActual< + typeof import("@/lib/stripe/validateCreateSubscriptionPortalBody") + >("@/lib/stripe/validateCreateSubscriptionPortalBody"); + return mod.validateCreateSubscriptionPortalBody; +} + +describe("POST /api/subscriptions/portal (validation)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateSubscriptionPortalBody).mockReset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.mocked(console.error).mockRestore(); + }); + + it("returns 400 when body is invalid JSON", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockImplementationOnce( + await loadRealValidate(), + ); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { + 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(createBillingPortalSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when returnUrl is missing", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockImplementationOnce( + await loadRealValidate(), + ); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({}), + }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body).toEqual({ error: expect.stringMatching(/returnUrl|Invalid input/i) }); + expect(createBillingPortalSession).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(validateCreateSubscriptionPortalBody).mockImplementationOnce( + await loadRealValidate(), + ); + const res = await POST( + new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/billing" }), + }), + ); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + expect(createBillingPortalSession).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/subscriptions/portal/__tests__/routeTestMocks.ts b/app/api/subscriptions/portal/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..2ba147680 --- /dev/null +++ b/app/api/subscriptions/portal/__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/validateCreateSubscriptionPortalBody", () => ({ + validateCreateSubscriptionPortalBody: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/createBillingPortalSession", () => ({ + createBillingPortalSession: vi.fn(), +})); diff --git a/app/api/subscriptions/portal/route.ts b/app/api/subscriptions/portal/route.ts new file mode 100644 index 000000000..89d0f319e --- /dev/null +++ b/app/api/subscriptions/portal/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createSubscriptionPortalHandler } from "@/lib/stripe/createSubscriptionPortalHandler"; + +/** + * 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: creates a subscription management (billing portal) session. + * + * @param request - The incoming HTTP request. + * @returns A NextResponse with portal session `id` and `url`, or an error body. + */ +export async function POST(request: NextRequest) { + return createSubscriptionPortalHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/__tests__/createSubscriptionPortalHandler.test.ts b/lib/stripe/__tests__/createSubscriptionPortalHandler.test.ts new file mode 100644 index 000000000..56ebb7ba8 --- /dev/null +++ b/lib/stripe/__tests__/createSubscriptionPortalHandler.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { createSubscriptionPortalHandler } from "@/lib/stripe/createSubscriptionPortalHandler"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateCreateSubscriptionPortalBody", () => ({ + validateCreateSubscriptionPortalBody: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/createBillingPortalSession", () => ({ + createBillingPortalSession: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("createSubscriptionPortalHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + 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(validateCreateSubscriptionPortalBody).mockResolvedValue(err); + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + body: "{}", + }); + expect(await createSubscriptionPortalHandler(req)).toBe(err); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns 200 with id and url", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + customer: "cus_test_123", + } as Awaited>); + vi.mocked(createBillingPortalSession).mockResolvedValue({ + id: "bps_test_abc", + url: "https://billing.example.com/session/abc", + } as Awaited>); + + const res = await createSubscriptionPortalHandler( + new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: "bps_test_abc", + url: "https://billing.example.com/session/abc", + }); + }); + + it("returns 500 when createBillingPortalSession throws", async () => { + vi.mocked(validateCreateSubscriptionPortalBody).mockResolvedValue({ + accountId: ACCOUNT, + returnUrl: "https://chat.recoupable.com/billing", + }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + customer: "cus_test_123", + } as Awaited>); + vi.mocked(createBillingPortalSession).mockRejectedValue(new Error("Stripe down")); + + const res = await createSubscriptionPortalHandler( + new NextRequest("http://localhost/api/subscriptions/portal", { method: "POST", body: "{}" }), + ); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Internal server error" }); + }); +}); diff --git a/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.auth.test.ts b/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.auth.test.ts new file mode 100644 index 000000000..a022385f2 --- /dev/null +++ b/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.auth.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("validateCreateSubscriptionPortalBody (auth)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("maps auth failure to { error } and preserves status", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/billing" }), + }); + const res = await validateCreateSubscriptionPortalBody(req); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + }); + + it("returns accountId from auth and returnUrl from body when auth succeeds", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "t", + }); + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({ returnUrl: "https://chat.recoupable.com/billing" }), + }); + const out = await validateCreateSubscriptionPortalBody(req); + expect(out).toEqual({ accountId: ACCOUNT, returnUrl: "https://chat.recoupable.com/billing" }); + expect(validateAuthContext).toHaveBeenCalledWith(req, {}); + }); +}); diff --git a/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.body.test.ts b/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.body.test.ts new file mode 100644 index 000000000..d7891fa69 --- /dev/null +++ b/lib/stripe/__tests__/validateCreateSubscriptionPortalBody.body.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("validateCreateSubscriptionPortalBody (body)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns 400 { error } for invalid JSON", async () => { + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: "not-json", + }); + const res = await validateCreateSubscriptionPortalBody(req); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid JSON body" }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 400 { error } when returnUrl is missing", async () => { + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({}), + }); + const res = await validateCreateSubscriptionPortalBody(req); + expect(res.status).toBe(400); + const j = await res.json(); + expect(j).toEqual({ error: expect.stringMatching(/returnUrl|Invalid input/i) }); + }); + + it("returns 400 for unknown body keys (strict)", async () => { + const req = new NextRequest("http://localhost/api/subscriptions/portal", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: JSON.stringify({ + returnUrl: "https://chat.recoupable.com/billing", + extra: true, + }), + }); + expect((await validateCreateSubscriptionPortalBody(req)).status).toBe(400); + }); +}); diff --git a/lib/stripe/createBillingPortalSession.ts b/lib/stripe/createBillingPortalSession.ts new file mode 100644 index 000000000..afb40bc53 --- /dev/null +++ b/lib/stripe/createBillingPortalSession.ts @@ -0,0 +1,12 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +export async function createBillingPortalSession( + stripeCustomerId: string, + returnUrl: string, +): Promise { + return stripeClient.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl, + }); +} diff --git a/lib/stripe/createSubscriptionPortalHandler.ts b/lib/stripe/createSubscriptionPortalHandler.ts new file mode 100644 index 000000000..d6b659f9b --- /dev/null +++ b/lib/stripe/createSubscriptionPortalHandler.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createBillingPortalSession } from "@/lib/stripe/createBillingPortalSession"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { validateCreateSubscriptionPortalBody } from "@/lib/stripe/validateCreateSubscriptionPortalBody"; + +export async function createSubscriptionPortalHandler(request: NextRequest): Promise { + try { + const validated = await validateCreateSubscriptionPortalBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const subscription = await getActiveSubscriptionDetails(validated.accountId); + if (!subscription) { + return NextResponse.json( + { error: "No active subscription found" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const session = await createBillingPortalSession( + subscription.customer as string, + validated.returnUrl, + ); + if (!session.url) { + return NextResponse.json( + { error: "Billing portal URL missing" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { id: session.id, url: session.url }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[createSubscriptionPortalHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/validateCreateSubscriptionPortalBody.ts b/lib/stripe/validateCreateSubscriptionPortalBody.ts new file mode 100644 index 000000000..3f026e84f --- /dev/null +++ b/lib/stripe/validateCreateSubscriptionPortalBody.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +export const createSubscriptionPortalBodySchema = z + .object({ + returnUrl: z.string().min(1, "returnUrl is required").url("returnUrl must be a valid URL"), + }) + .strict(); + +export type CreateSubscriptionPortalBody = z.infer; + +export type ValidatedCreateSubscriptionPortalBody = { + accountId: string; + returnUrl: string; +}; + +export async function validateCreateSubscriptionPortalBody( + 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 = createSubscriptionPortalBodySchema.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, + }; +}