diff --git a/app/api/accounts/[id]/subscription/__tests__/route.test.ts b/app/api/accounts/[id]/subscription/__tests__/route.test.ts new file mode 100644 index 000000000..6943eba98 --- /dev/null +++ b/app/api/accounts/[id]/subscription/__tests__/route.test.ts @@ -0,0 +1,46 @@ +import "./routeTestMocks"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +const { GET, OPTIONS } = await import("../route"); + +const ACCOUNT_ID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("app/api/accounts/[id]/subscription/route", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OPTIONS 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("*"); + }); + + it("GET delegates to getAccountSubscriptionHandler with the path params", async () => { + const handlerRes = NextResponse.json( + { isPro: true, status: "active", plan: "pro", source: "account" }, + { status: 200 }, + ); + vi.mocked(getAccountSubscriptionHandler).mockResolvedValue(handlerRes); + + const req = new NextRequest(`http://localhost/api/accounts/${ACCOUNT_ID}/subscription`, { + headers: { "x-api-key": "test-key" }, + }); + const params = Promise.resolve({ id: ACCOUNT_ID }); + const res = await GET(req, { params }); + + expect(getAccountSubscriptionHandler).toHaveBeenCalledTimes(1); + expect(getAccountSubscriptionHandler).toHaveBeenCalledWith(req, params); + expect(res).toBe(handlerRes); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); +}); diff --git a/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..b9d9a2994 --- /dev/null +++ b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts @@ -0,0 +1,9 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/getAccountSubscriptionHandler", () => ({ + getAccountSubscriptionHandler: vi.fn(), +})); diff --git a/app/api/accounts/[id]/subscription/route.ts b/app/api/accounts/[id]/subscription/route.ts new file mode 100644 index 000000000..e7e06730a --- /dev/null +++ b/app/api/accounts/[id]/subscription/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/accounts/[id]/subscription + * + * Returns the subscription resource for an account, including coverage via organization + * membership. Requires authentication via `x-api-key` or `Authorization: Bearer`; the caller + * must be the account itself or have access via organization membership. + * + * @param request - Incoming request; auth is read from headers. + * @param context - Route context from Next.js. + * @param context.params - Promise resolving to `{ id }`, the account UUID from the URL path. + * @returns A 200 NextResponse with `{ isPro, status, plan, source }`, or 4xx with `{ error }`. + */ +export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { + return getAccountSubscriptionHandler(request, context.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/__tests__/buildSubscriptionResponse.test.ts b/lib/stripe/__tests__/buildSubscriptionResponse.test.ts new file mode 100644 index 000000000..83acd562c --- /dev/null +++ b/lib/stripe/__tests__/buildSubscriptionResponse.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import { buildSubscriptionResponse } from "@/lib/stripe/buildSubscriptionResponse"; + +const activeSub = (status: Stripe.Subscription.Status = "active") => + ({ status, canceled_at: null }) as unknown as Stripe.Subscription; + +describe("buildSubscriptionResponse", () => { + it("returns isPro:false / none / null / null when neither subscription is active", () => { + expect(buildSubscriptionResponse({ account: null, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); + + it("prefers the account subscription when active", () => { + expect( + buildSubscriptionResponse({ + account: activeSub("active"), + organization: activeSub("trialing"), + }), + ).toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); + + it("falls back to the organization subscription when only org is active", () => { + expect( + buildSubscriptionResponse({ + account: null, + organization: activeSub("trialing"), + }), + ).toEqual({ + isPro: true, + status: "trialing", + plan: "pro", + source: "organization", + }); + }); + + it("treats trialing-with-canceled_at as inactive", () => { + const canceledTrial = { + status: "trialing", + canceled_at: 1700000000, + } as unknown as Stripe.Subscription; + + expect(buildSubscriptionResponse({ account: canceledTrial, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); + + it("normalizes unsupported Stripe statuses to 'none' when somehow active", () => { + const weird = { status: "incomplete", canceled_at: null } as unknown as Stripe.Subscription; + expect(buildSubscriptionResponse({ account: weird, organization: null })).toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); +}); diff --git a/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts b/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts new file mode 100644 index 000000000..e874548eb --- /dev/null +++ b/lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getAccountSubscriptionHandler } from "@/lib/stripe/getAccountSubscriptionHandler"; + +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateAccountSubscriptionParams", () => ({ + validateAccountSubscriptionParams: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +const buildRequest = () => new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`); + +const buildParams = () => Promise.resolve({ id: ACCOUNT }); + +describe("getAccountSubscriptionHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards validation/auth errors as { error } with original status", async () => { + const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(denial); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(401); + await expect(res.json()).resolves.toEqual({ error: "Unauthorized" }); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns the resource shape for an active account subscription", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + id: "sub_1", + status: "active", + canceled_at: null, + } as never); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "active", + plan: "pro", + source: "account", + }); + }); + + it("returns source: organization when only the org subscription is active", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue({ + id: "sub_org", + status: "trialing", + canceled_at: null, + } as never); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: true, + status: "trialing", + plan: "pro", + source: "organization", + }); + }); + + it("returns isPro:false / none / null when neither subscription is active", async () => { + vi.mocked(validateAccountSubscriptionParams).mockResolvedValue(ACCOUNT); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const res = await getAccountSubscriptionHandler(buildRequest(), buildParams()); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + isPro: false, + status: "none", + plan: null, + source: null, + }); + }); +}); diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts new file mode 100644 index 000000000..a352d8ee8 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import stripeClient from "@/lib/stripe/client"; +import { getActiveSubscriptionsTestHelpers } from "./getActiveSubscriptionsTestHelpers"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { subscriptions: { list: vi.fn() } }, +})); + +const { + testAccountId: ACC, + subscription: sub, + subscriptionListPage: apiList, +} = getActiveSubscriptionsTestHelpers(); +const list = () => vi.mocked(stripeClient.subscriptions.list); + +describe("getActiveSubscriptions", () => { + beforeEach(() => vi.clearAllMocks()); + + it("walks pages until a batch matches accountId", async () => { + list() + .mockResolvedValueOnce(apiList([sub("sub_x", "other")], true)) + .mockResolvedValueOnce(apiList([sub("sub_1", ACC)], true)); + const result = await getActiveSubscriptions(ACC); + expect(list()).toHaveBeenCalledTimes(2); + expect(result.map(s => s.id)).toEqual(["sub_1"]); + expect(list().mock.calls[1][0]).toMatchObject({ starting_after: "sub_x", limit: 100 }); + }); + + it("finds a match on a later page (no artificial page limit)", async () => { + for (let i = 0; i < 52; i++) + list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], true)); + list().mockResolvedValueOnce(apiList([sub("sub_late", ACC)], false)); + const result = await getActiveSubscriptions(ACC); + expect(result.map(s => s.id)).toEqual(["sub_late"]); + expect(list()).toHaveBeenCalledTimes(53); + }); + + it("stops after the first page that includes a match", async () => { + list().mockResolvedValueOnce( + apiList([sub("sub_x", "other"), sub("sub_1", ACC), sub("sub_2", ACC)], true), + ); + const result = await getActiveSubscriptions(ACC); + expect(list()).toHaveBeenCalledTimes(1); + expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); + }); + + it("returns [] when nothing matches after Stripe exhausts pages", async () => { + for (let i = 0; i < 3; i++) { + list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], i < 2)); + } + const result = await getActiveSubscriptions(ACC); + expect(result).toEqual([]); + expect(list()).toHaveBeenCalledTimes(3); + }); + + it("breaks if pagination cursor does not advance", async () => { + const s = sub("sub_stuck", "other"); + list() + .mockResolvedValueOnce(apiList([s], true)) + .mockResolvedValueOnce(apiList([s], true)); + const result = await getActiveSubscriptions(ACC); + expect(result).toEqual([]); + expect(list()).toHaveBeenCalledTimes(2); + }); + + it("returns [] when Stripe throws", async () => { + list().mockRejectedValue(new Error("stripe error")); + await expect(getActiveSubscriptions(ACC)).resolves.toEqual([]); + }); +}); diff --git a/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts new file mode 100644 index 000000000..08e11bb89 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts @@ -0,0 +1,18 @@ +import type Stripe from "stripe"; + +export function getActiveSubscriptionsTestHelpers() { + const testAccountId = "acc-a"; + + function subscription(id: string, accountId: string): Stripe.Subscription { + return { id, metadata: { accountId } } as Stripe.Subscription; + } + + function subscriptionListPage( + data: Stripe.Subscription[], + hasMore: boolean, + ): Stripe.Response> { + return { data, has_more: hasMore } as Stripe.Response>; + } + + return { testAccountId, subscription, subscriptionListPage }; +} diff --git a/lib/stripe/__tests__/getOrgSubscription.test.ts b/lib/stripe/__tests__/getOrgSubscription.test.ts new file mode 100644 index 000000000..791c4a57d --- /dev/null +++ b/lib/stripe/__tests__/getOrgSubscription.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +describe("getOrgSubscription", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when accountId is empty", async () => { + await expect(getOrgSubscription("")).resolves.toBeNull(); + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns first org subscription and avoids extra Stripe work after a match", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "org-a" }, + { organization_id: "org-b" }, + { organization_id: "org-c" }, + ] as Awaited>); + + const sub = { id: "sub_from_a" } as Stripe.Subscription; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(sub); + + await expect(getOrgSubscription("acc-1")).resolves.toBe(sub); + + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(1); + expect(getActiveSubscriptionDetails).toHaveBeenCalledWith("org-a"); + }); + + it("walks orgs in order until a subscription is found", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "org-a" }, + { organization_id: "org-b" }, + ] as Awaited>); + + const sub = { id: "sub_from_b" } as Stripe.Subscription; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValueOnce(null).mockResolvedValueOnce(sub); + + await expect(getOrgSubscription("acc-1")).resolves.toBe(sub); + + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(2); + expect(getActiveSubscriptionDetails).toHaveBeenNthCalledWith(1, "org-a"); + expect(getActiveSubscriptionDetails).toHaveBeenNthCalledWith(2, "org-b"); + }); + + it("returns null when no org has a subscription", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([{ organization_id: "org-a" }] as Awaited< + ReturnType + >); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + + await expect(getOrgSubscription("acc-1")).resolves.toBeNull(); + expect(getActiveSubscriptionDetails).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/stripe/__tests__/isActiveSubscription.test.ts b/lib/stripe/__tests__/isActiveSubscription.test.ts new file mode 100644 index 000000000..ab99f56d7 --- /dev/null +++ b/lib/stripe/__tests__/isActiveSubscription.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; + +function sub( + partial: Pick & Partial, +): Stripe.Subscription { + return partial as Stripe.Subscription; +} + +describe("isActiveSubscription", () => { + it("returns false for null/undefined", () => { + expect(isActiveSubscription(null)).toBe(false); + expect(isActiveSubscription(undefined)).toBe(false); + }); + + it("returns true for status active", () => { + expect(isActiveSubscription(sub({ status: "active" }))).toBe(true); + }); + + it("returns true for trialing without canceled_at", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: null }))).toBe(true); + }); + + it("returns false for trialing with canceled_at (canceled trial)", () => { + expect(isActiveSubscription(sub({ status: "trialing", canceled_at: 1234567890 }))).toBe(false); + }); + + it("returns false for canceled and other non-entitled statuses", () => { + expect(isActiveSubscription(sub({ status: "canceled" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "unpaid" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "past_due" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "incomplete" }))).toBe(false); + expect(isActiveSubscription(sub({ status: "incomplete_expired" }))).toBe(false); + }); +}); diff --git a/lib/stripe/__tests__/toStatus.test.ts b/lib/stripe/__tests__/toStatus.test.ts new file mode 100644 index 000000000..b291db150 --- /dev/null +++ b/lib/stripe/__tests__/toStatus.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import type Stripe from "stripe"; +import { toStatus } from "@/lib/stripe/toStatus"; + +describe("toStatus", () => { + it.each([["active"], ["trialing"], ["canceled"], ["past_due"]] as const)( + "passes through supported Stripe status %s", + status => { + expect(toStatus(status as Stripe.Subscription.Status)).toBe(status); + }, + ); + + it.each([["incomplete"], ["incomplete_expired"], ["unpaid"], ["paused"]] as const)( + "normalizes unsupported status %s to 'none'", + status => { + expect(toStatus(status as Stripe.Subscription.Status)).toBe("none"); + }, + ); +}); diff --git a/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts b/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts new file mode 100644 index 000000000..64dd0cf5d --- /dev/null +++ b/lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +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"; + +const getRequest = () => + new NextRequest(`http://localhost/api/accounts/${ACCOUNT}/subscription`, { + headers: { "x-api-key": "test-key" }, + }); + +describe("validateAccountSubscriptionParams", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when id is not a valid UUID", async () => { + const res = await validateAccountSubscriptionParams(getRequest(), "not-a-uuid"); + expect(res).toBeInstanceOf(NextResponse); + const response = res as NextResponse; + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toMatch(/id must be a valid UUID/i); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("forwards the auth response when authentication fails", async () => { + const denial = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(denial); + + const req = getRequest(); + const res = await validateAccountSubscriptionParams(req, ACCOUNT); + expect(res).toBe(denial); + expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ACCOUNT }); + }); + + it("returns the validated accountId when auth succeeds", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "tok", + }); + + const res = await validateAccountSubscriptionParams(getRequest(), ACCOUNT); + expect(res).toBe(ACCOUNT); + }); +}); diff --git a/lib/stripe/buildSubscriptionResponse.ts b/lib/stripe/buildSubscriptionResponse.ts new file mode 100644 index 000000000..12b99031d --- /dev/null +++ b/lib/stripe/buildSubscriptionResponse.ts @@ -0,0 +1,47 @@ +import type Stripe from "stripe"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { toStatus, type SubscriptionStatus } from "@/lib/stripe/toStatus"; + +export type SubscriptionSource = "account" | "organization"; +export type { SubscriptionStatus }; + +export interface SubscriptionResponse { + isPro: boolean; + status: SubscriptionStatus; + plan: string | null; + source: SubscriptionSource | null; +} + +const inactive: SubscriptionResponse = { + isPro: false, + status: "none", + plan: null, + source: null, +}; + +/** + * Maps the account- and organization-level subscriptions into the documented response shape. + * Account subscription wins when both are active. + */ +export function buildSubscriptionResponse(args: { + account: Stripe.Subscription | null; + organization: Stripe.Subscription | null; +}): SubscriptionResponse { + if (isActiveSubscription(args.account) && args.account) { + return { + isPro: true, + status: toStatus(args.account.status), + plan: "pro", + source: "account", + }; + } + if (isActiveSubscription(args.organization) && args.organization) { + return { + isPro: true, + status: toStatus(args.organization.status), + plan: "pro", + source: "organization", + }; + } + return inactive; +} diff --git a/lib/stripe/getAccountSubscriptionHandler.ts b/lib/stripe/getAccountSubscriptionHandler.ts new file mode 100644 index 000000000..92e44d3e5 --- /dev/null +++ b/lib/stripe/getAccountSubscriptionHandler.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { validateAccountSubscriptionParams } from "@/lib/stripe/validateAccountSubscriptionParams"; +import { buildSubscriptionResponse } from "@/lib/stripe/buildSubscriptionResponse"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +/** + * GET /api/accounts/[id]/subscription + * + * Returns the documented subscription resource for an account, including coverage + * via organization membership. Forwards auth/validation failures as `{ error }` bodies. + */ +export async function getAccountSubscriptionHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + const validated = await validateAccountSubscriptionParams(request, id); + if (validated instanceof NextResponse) { + return mapToSubscriptionSessionError(validated); + } + + const [account, organization] = await Promise.all([ + getActiveSubscriptionDetails(validated), + getOrgSubscription(validated), + ]); + + return NextResponse.json(buildSubscriptionResponse({ account, organization }), { + status: 200, + headers: getCorsHeaders(), + }); + } catch (error) { + console.error("[getAccountSubscriptionHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..3604de673 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,11 @@ +import { getActiveSubscriptions } from "./getActiveSubscriptions"; + +export const getActiveSubscriptionDetails = async (accountId: string) => { + try { + const activeSubscriptions = await getActiveSubscriptions(accountId); + return activeSubscriptions.length > 0 ? activeSubscriptions[0] : null; + } catch (error) { + console.error("Error fetching subscription:", error); + return null; + } +}; diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..be0c603f8 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,49 @@ +import stripeClient from "@/lib/stripe/client"; +import Stripe from "stripe"; + +const PAGE_LIMIT = 100; + +/** + * Lists active subscriptions whose `metadata.accountId` matches. + * Stops after the first page that yields a match (callers only need one). + * Paginates until Stripe reports no more pages (no fixed page cap — avoids missing matches deep in the list). + */ +export const getActiveSubscriptions = async (accountId: string) => { + try { + const now = Math.floor(Date.now() / 1000); + const activeSubscriptions: Stripe.Subscription[] = []; + let startingAfter: string | undefined; + let hasMore = true; + + while (hasMore) { + const listParams: Stripe.SubscriptionListParams = { + limit: PAGE_LIMIT, + current_period_end: { gt: now }, + }; + if (startingAfter) { + listParams.starting_after = startingAfter; + } + + const page = await stripeClient.subscriptions.list(listParams); + + activeSubscriptions.push( + ...page.data.filter((s: Stripe.Subscription) => s.metadata?.accountId === accountId), + ); + + if (activeSubscriptions.length > 0) { + break; + } + + hasMore = page.has_more; + const lastId = page.data.at(-1)?.id; + if (!lastId) break; + if (startingAfter !== undefined && lastId === startingAfter) break; + startingAfter = lastId; + } + + return activeSubscriptions; + } catch (error) { + console.error("Error fetching subscriptions:", error); + return []; + } +}; diff --git a/lib/stripe/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts new file mode 100644 index 000000000..f8944ca73 --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,24 @@ +import { getActiveSubscriptionDetails } from "./getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import Stripe from "stripe"; + +/** + * First active Stripe subscription for any organization linked to the account, if any. + */ +export async function getOrgSubscription(accountId: string): Promise { + if (!accountId) return null; + + const accountOrgs = await getAccountOrganizations({ accountId }); + if (accountOrgs.length === 0) return null; + + const orgIds = accountOrgs + .map(org => org.organization_id) + .filter((id): id is string => id !== null); + + for (const orgId of orgIds) { + const sub = await getActiveSubscriptionDetails(orgId); + if (sub) return sub; + } + + return null; +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..8ceaefd48 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,12 @@ +import Stripe from "stripe"; + +/** Stripe statuses that may grant paid access; others (e.g. canceled, unpaid) are not active. */ +const isActiveSubscription = (subscription?: Stripe.Subscription | null) => { + if (!subscription) return false; + const { status, canceled_at: canceledAt } = subscription; + if (status === "active") return true; + if (status === "trialing") return !canceledAt; + return false; +}; + +export default isActiveSubscription; diff --git a/lib/stripe/toStatus.ts b/lib/stripe/toStatus.ts new file mode 100644 index 000000000..7e67890e6 --- /dev/null +++ b/lib/stripe/toStatus.ts @@ -0,0 +1,20 @@ +import type Stripe from "stripe"; + +export type SubscriptionStatus = "active" | "trialing" | "canceled" | "past_due" | "none"; + +const SUPPORTED: ReadonlySet = new Set([ + "active", + "trialing", + "canceled", + "past_due", +]); + +/** + * Maps a Stripe subscription status to the documented `SubscriptionStatus` enum. + * Unsupported Stripe statuses (e.g. `incomplete`, `unpaid`) collapse to `"none"`. + */ +export function toStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { + return SUPPORTED.has(stripeStatus as SubscriptionStatus) + ? (stripeStatus as SubscriptionStatus) + : "none"; +} diff --git a/lib/stripe/validateAccountSubscriptionParams.ts b/lib/stripe/validateAccountSubscriptionParams.ts new file mode 100644 index 000000000..a03ff23d2 --- /dev/null +++ b/lib/stripe/validateAccountSubscriptionParams.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const idSchema = z.string().uuid("id must be a valid UUID"); + +/** + * Validates the `[id]` path param and confirms the caller may access that account. + * + * @returns The validated account UUID, or a NextResponse with the error to forward. + */ +export async function validateAccountSubscriptionParams( + request: NextRequest, + id: string, +): Promise { + const parsed = idSchema.safeParse(id); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const auth = await validateAuthContext(request, { accountId: parsed.data }); + if (auth instanceof NextResponse) { + return auth; + } + + return parsed.data; +}