From 9b0964cbc8f38a1ae1158d3e19e53238f376e2d7 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 23:16:50 +0700 Subject: [PATCH 01/13] chore(sandbox): merge updates from main and align with @vercel/sandbox v2.0.0-beta.11 This commit merges the latest changes from the main branch and ensures compatibility with the updated @vercel/sandbox version. The API has been adjusted to reflect the renaming of Sandbox.sandboxId to Sandbox.name, with corresponding updates to method parameters. All relevant tests have been updated to mock the new Sandbox structure and verify functionality. Verification steps have been executed successfully, confirming no issues with installation, type checking, linting, or tests. --- .../status/__tests__/route.test.ts | 11 +++ .../status/__tests__/routeTestMocks.ts | 9 +++ app/api/subscriptions/status/route.ts | 29 +++++++ .../getSubscriptionStatusHandler.test.ts | 81 +++++++++++++++++++ ...lidateGetSubscriptionStatusRequest.test.ts | 75 +++++++++++++++++ lib/stripe/getActiveSubscriptionDetails.ts | 11 +++ lib/stripe/getActiveSubscriptions.ts | 22 +++++ lib/stripe/getOrgSubscription.ts | 21 +++++ lib/stripe/getSubscriptionStatusHandler.ts | 31 +++++++ lib/stripe/isActiveSubscription.ts | 11 +++ .../validateGetSubscriptionStatusRequest.ts | 39 +++++++++ 11 files changed, 340 insertions(+) create mode 100644 app/api/subscriptions/status/__tests__/route.test.ts create mode 100644 app/api/subscriptions/status/__tests__/routeTestMocks.ts create mode 100644 app/api/subscriptions/status/route.ts create mode 100644 lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts create mode 100644 lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts create mode 100644 lib/stripe/getActiveSubscriptionDetails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/getOrgSubscription.ts create mode 100644 lib/stripe/getSubscriptionStatusHandler.ts create mode 100644 lib/stripe/isActiveSubscription.ts create mode 100644 lib/stripe/validateGetSubscriptionStatusRequest.ts diff --git a/app/api/subscriptions/status/__tests__/route.test.ts b/app/api/subscriptions/status/__tests__/route.test.ts new file mode 100644 index 000000000..4c7210b54 --- /dev/null +++ b/app/api/subscriptions/status/__tests__/route.test.ts @@ -0,0 +1,11 @@ +import "./routeTestMocks"; +import { describe, it, expect } from "vitest"; + +const { GET, OPTIONS } = await import("../route"); + +describe("app/api/subscriptions/status/route", () => { + it("exports GET and OPTIONS handlers", () => { + expect(typeof GET).toBe("function"); + expect(typeof OPTIONS).toBe("function"); + }); +}); diff --git a/app/api/subscriptions/status/__tests__/routeTestMocks.ts b/app/api/subscriptions/status/__tests__/routeTestMocks.ts new file mode 100644 index 000000000..117219b00 --- /dev/null +++ b/app/api/subscriptions/status/__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/getSubscriptionStatusHandler", () => ({ + getSubscriptionStatusHandler: vi.fn(), +})); diff --git a/app/api/subscriptions/status/route.ts b/app/api/subscriptions/status/route.ts new file mode 100644 index 000000000..ce8fecbb5 --- /dev/null +++ b/app/api/subscriptions/status/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/subscriptions/status: returns whether the account has active paid access (direct or via organization). + * + * @param request - The incoming HTTP request (query `accountId` required). + * @returns JSON `{ isPro }` or an `{ error }` body with 4xx status. + */ +export async function GET(request: NextRequest) { + return getSubscriptionStatusHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts new file mode 100644 index 000000000..6bf4a1000 --- /dev/null +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; + +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ + validateGetSubscriptionStatusRequest: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +vi.mock("@/lib/stripe/isActiveSubscription", () => ({ + default: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("getSubscriptionStatusHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards validation error response", async () => { + const denied = NextResponse.json({ error: "accountId is required" }, { status: 400 }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(denied); + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await getSubscriptionStatusHandler(req); + expect(res.status).toBe(400); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns { isPro: true } when account subscription is active", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ id: "sub_1" } as never); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await getSubscriptionStatusHandler(req); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + }); + + it("returns { isPro: true } when only org subscription is active", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue({ id: "sub_org" } as never); + vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await getSubscriptionStatusHandler(req); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + }); + + it("returns { isPro: false } when neither subscription is active", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isActiveSubscription).mockReturnValue(false); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await getSubscriptionStatusHandler(req); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: false }); + }); +}); diff --git a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts new file mode 100644 index 000000000..be3ad8053 --- /dev/null +++ b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +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"; + +function getRequest(url: string) { + return new NextRequest(url, { headers: { "x-api-key": "test-key" } }); +} + +describe("validateGetSubscriptionStatusRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 { error: accountId is required } when accountId is missing", async () => { + const req = getRequest("http://localhost/api/subscriptions/status"); + const res = await validateGetSubscriptionStatusRequest(req); + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 400 when accountId is empty string", async () => { + const req = getRequest(`http://localhost/api/subscriptions/status?accountId=`); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(400); + await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); + }); + + it("returns 400 for invalid UUID", async () => { + const req = getRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(400); + const body = await (res as NextResponse).json(); + expect(body.error).toMatch(/accountId must be a valid UUID/i); + }); + + 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 = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(401); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + }); + + it("returns accountId when auth succeeds", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "tok", + }); + const req = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); + const res = await validateGetSubscriptionStatusRequest(req); + expect(res).toEqual({ accountId: ACCOUNT }); + expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ACCOUNT }); + }); +}); 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..37639ce67 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,22 @@ +import stripeClient from "@/lib/stripe/client"; +import Stripe from "stripe"; + +export const getActiveSubscriptions = async (accountId: string) => { + try { + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { + gt: parseInt(Number(Date.now() / 1000).toFixed(0), 10), + }, + }); + + const activeSubscriptions = subscriptions?.data?.filter( + (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, + ); + + 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..1c929e9d9 --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,21 @@ +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); + + const subscriptions = await Promise.all(orgIds.map(orgId => getActiveSubscriptionDetails(orgId))); + + return subscriptions.find(sub => sub !== null) ?? null; +} diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts new file mode 100644 index 000000000..9bc3c5d5b --- /dev/null +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -0,0 +1,31 @@ +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 isActiveSubscription from "@/lib/stripe/isActiveSubscription"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; + +export async function getSubscriptionStatusHandler(request: NextRequest): Promise { + try { + const validated = await validateGetSubscriptionStatusRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const [accountSubscription, orgSubscription] = await Promise.all([ + getActiveSubscriptionDetails(validated.accountId), + getOrgSubscription(validated.accountId), + ]); + + const isPro = + isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); + + return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[getSubscriptionStatusHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..abab39378 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,11 @@ +import Stripe from "stripe"; + +const isActiveSubscription = (subscription?: Stripe.Subscription | null) => { + if (!subscription) return false; + const isTrial = subscription?.status === "trialing"; + const isCanceledTrial = isTrial && subscription?.canceled_at; + const subscriptionActive = !isCanceledTrial; + return subscriptionActive; +}; + +export default isActiveSubscription; diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateGetSubscriptionStatusRequest.ts new file mode 100644 index 000000000..4d69de9e0 --- /dev/null +++ b/lib/stripe/validateGetSubscriptionStatusRequest.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from "next/server"; +import { 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"; + +const querySchema = z.object({ + accountId: z.string().uuid("accountId must be a valid UUID"), +}); + +export type ValidatedGetSubscriptionStatusRequest = { + accountId: string; +}; + +export async function validateGetSubscriptionStatusRequest( + request: NextRequest, +): Promise { + const raw = request.nextUrl.searchParams.get("accountId"); + if (raw === null || raw === "") { + return NextResponse.json( + { error: "accountId is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsed = querySchema.safeParse({ accountId: raw }); + if (!parsed.success) { + const first = parsed.error.issues[0]; + return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); + } + + const authContext = await validateAuthContext(request, { accountId: parsed.data.accountId }); + if (authContext instanceof NextResponse) { + return mapToSubscriptionSessionError(authContext); + } + + return { accountId: authContext.accountId }; +} From 05774b97cb8019b4393a278d20d86946131c72b9 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 23:50:06 +0700 Subject: [PATCH 02/13] feat(stripe): enhance isActiveSubscription logic and add tests - Refactored the isActiveSubscription function to improve clarity and efficiency in determining subscription status. - Added comprehensive unit tests for isActiveSubscription to cover various subscription states, including active, trialing, and canceled scenarios. - Ensured that the function correctly handles null or undefined inputs, returning false as expected. --- .../__tests__/isActiveSubscription.test.ts | 36 +++++++++++++++++++ lib/stripe/isActiveSubscription.ts | 9 ++--- 2 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 lib/stripe/__tests__/isActiveSubscription.test.ts 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/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts index abab39378..8ceaefd48 100644 --- a/lib/stripe/isActiveSubscription.ts +++ b/lib/stripe/isActiveSubscription.ts @@ -1,11 +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 isTrial = subscription?.status === "trialing"; - const isCanceledTrial = isTrial && subscription?.canceled_at; - const subscriptionActive = !isCanceledTrial; - return subscriptionActive; + const { status, canceled_at: canceledAt } = subscription; + if (status === "active") return true; + if (status === "trialing") return !canceledAt; + return false; }; export default isActiveSubscription; From 536fa9d8304711fe6ff66129f9924951665a5cd1 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:06:13 +0700 Subject: [PATCH 03/13] feat(stripe): improve getActiveSubscriptions to handle pagination and enhance filtering - Refactored getActiveSubscriptions to implement pagination for fetching active subscriptions from Stripe, allowing for more than 100 results. - Introduced a constant PAGE_LIMIT for better maintainability of the subscription listing limit. - Enhanced filtering logic to ensure only subscriptions matching the specified accountId are returned. - Improved error handling to log issues encountered during the subscription fetching process. --- .../__tests__/getActiveSubscriptions.test.ts | 48 +++++++++++++++++++ lib/stripe/getActiveSubscriptions.ts | 37 ++++++++++---- 2 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts new file mode 100644 index 000000000..a7baecf86 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type Stripe from "stripe"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import stripeClient from "@/lib/stripe/client"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { + subscriptions: { list: vi.fn() }, + }, +})); + +describe("getActiveSubscriptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("collects subscriptions matching accountId across paginated pages", async () => { + const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; + const sub2 = { id: "sub_2", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + + vi.mocked(stripeClient.subscriptions.list) + .mockResolvedValueOnce({ + data: [subOther, sub1], + has_more: true, + } as Stripe.Response>) + .mockResolvedValueOnce({ + data: [sub2], + has_more: false, + } as Stripe.Response>); + + const result = await getActiveSubscriptions("acc-a"); + + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(2); + expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); + + const secondCall = vi.mocked(stripeClient.subscriptions.list).mock.calls[1][0]; + expect(secondCall).toMatchObject({ + starting_after: "sub_1", + limit: 100, + }); + }); + + it("returns [] when Stripe throws", async () => { + vi.mocked(stripeClient.subscriptions.list).mockRejectedValue(new Error("stripe error")); + await expect(getActiveSubscriptions("acc-a")).resolves.toEqual([]); + }); +}); diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 37639ce67..e9ce9415c 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -1,20 +1,37 @@ import stripeClient from "@/lib/stripe/client"; import Stripe from "stripe"; +const PAGE_LIMIT = 100; + export const getActiveSubscriptions = async (accountId: string) => { try { - const subscriptions = await stripeClient.subscriptions.list({ - limit: 100, - current_period_end: { - gt: parseInt(Number(Date.now() / 1000).toFixed(0), 10), - }, - }); + 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), + ); - const activeSubscriptions = subscriptions?.data?.filter( - (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, - ); + hasMore = page.has_more; + const lastId = page.data.at(-1)?.id; + if (!lastId) break; + startingAfter = lastId; + } - return activeSubscriptions || []; + return activeSubscriptions; } catch (error) { console.error("Error fetching subscriptions:", error); return []; From 97abf90a2162b2a7e38ed9dbaa307a50823e8c78 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:17:55 +0700 Subject: [PATCH 04/13] refactor(stripe): optimize getOrgSubscription to return first active subscription - Changed the implementation of getOrgSubscription to iterate through organization IDs and return the first active subscription found, improving efficiency by eliminating unnecessary parallel requests. - Removed the previous logic that collected all subscriptions and filtered them, simplifying the function's flow. --- .../__tests__/getOrgSubscription.test.ts | 66 +++++++++++++++++++ lib/stripe/getOrgSubscription.ts | 7 +- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 lib/stripe/__tests__/getOrgSubscription.test.ts 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/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts index 1c929e9d9..f8944ca73 100644 --- a/lib/stripe/getOrgSubscription.ts +++ b/lib/stripe/getOrgSubscription.ts @@ -15,7 +15,10 @@ export async function getOrgSubscription(accountId: string): Promise org.organization_id) .filter((id): id is string => id !== null); - const subscriptions = await Promise.all(orgIds.map(orgId => getActiveSubscriptionDetails(orgId))); + for (const orgId of orgIds) { + const sub = await getActiveSubscriptionDetails(orgId); + if (sub) return sub; + } - return subscriptions.find(sub => sub !== null) ?? null; + return null; } From 202fa587c2c9a4e00deab36bd6a57fa397b2ee6b Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:30:55 +0700 Subject: [PATCH 05/13] test(subscriptions): enhance route tests for GET and OPTIONS handlers - Added tests for OPTIONS handler to verify it returns 200 status with CORS headers. - Implemented tests for GET handler to ensure it correctly forwards requests to getSubscriptionStatusHandler and returns the expected response. - Introduced beforeEach hook to clear mocks before each test, improving test isolation. --- .../status/__tests__/route.test.ts | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/api/subscriptions/status/__tests__/route.test.ts b/app/api/subscriptions/status/__tests__/route.test.ts index 4c7210b54..25bc1e971 100644 --- a/app/api/subscriptions/status/__tests__/route.test.ts +++ b/app/api/subscriptions/status/__tests__/route.test.ts @@ -1,11 +1,36 @@ import "./routeTestMocks"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; const { GET, OPTIONS } = await import("../route"); +const ACCOUNT_ID = "123e4567-e89b-12d3-a456-426614174000"; + describe("app/api/subscriptions/status/route", () => { - it("exports GET and OPTIONS handlers", () => { - expect(typeof GET).toBe("function"); - expect(typeof OPTIONS).toBe("function"); + 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 forwards the request to getSubscriptionStatusHandler and returns its response", async () => { + const handlerRes = NextResponse.json({ isPro: true }, { status: 200 }); + vi.mocked(getSubscriptionStatusHandler).mockResolvedValue(handlerRes); + + const url = `http://localhost/api/subscriptions/status?accountId=${ACCOUNT_ID}`; + const req = new NextRequest(url, { headers: { "x-api-key": "test-key" } }); + const res = await GET(req); + + expect(getSubscriptionStatusHandler).toHaveBeenCalledTimes(1); + expect(getSubscriptionStatusHandler).toHaveBeenCalledWith(req); + expect(res).toBe(handlerRes); + await expect(res.json()).resolves.toEqual({ isPro: true }); }); }); From f43aab7a287ee61fac3f977e8ac61c93d71a444a Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:37:24 +0700 Subject: [PATCH 06/13] feat(stripe): export querySchema and simplify request validation - Exported the querySchema for use in other modules, enhancing reusability. - Simplified the ValidatedGetSubscriptionStatusRequest type by inferring it directly from querySchema, improving type safety and maintainability. --- lib/stripe/validateGetSubscriptionStatusRequest.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateGetSubscriptionStatusRequest.ts index 4d69de9e0..481cee9e8 100644 --- a/lib/stripe/validateGetSubscriptionStatusRequest.ts +++ b/lib/stripe/validateGetSubscriptionStatusRequest.ts @@ -5,13 +5,11 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; -const querySchema = z.object({ +export const querySchema = z.object({ accountId: z.string().uuid("accountId must be a valid UUID"), }); -export type ValidatedGetSubscriptionStatusRequest = { - accountId: string; -}; +export type ValidatedGetSubscriptionStatusRequest = z.infer; export async function validateGetSubscriptionStatusRequest( request: NextRequest, From 2be3abddd53a5dd9566bff01a0603798b92dbd6c Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:45:04 +0700 Subject: [PATCH 07/13] refactor(stripe): rename validation function and remove deprecated request validation - Renamed `validateGetSubscriptionStatusRequest` to `validateGetSubscriptionStatusQuery` for clarity and consistency. - Updated references in `getSubscriptionStatusHandler` and related tests to use the new validation function. - Removed the deprecated `validateGetSubscriptionStatusRequest` file and its associated tests, streamlining the codebase. --- .../__tests__/getSubscriptionStatusHandler.test.ts | 14 +++++++------- ... => validateGetSubscriptionStatusQuery.test.ts} | 14 +++++++------- lib/stripe/getSubscriptionStatusHandler.ts | 4 ++-- ...st.ts => validateGetSubscriptionStatusQuery.ts} | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) rename lib/stripe/__tests__/{validateGetSubscriptionStatusRequest.test.ts => validateGetSubscriptionStatusQuery.test.ts} (84%) rename lib/stripe/{validateGetSubscriptionStatusRequest.ts => validateGetSubscriptionStatusQuery.ts} (85%) diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts index 6bf4a1000..fb1f5536f 100644 --- a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; @@ -11,8 +11,8 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ - validateGetSubscriptionStatusRequest: vi.fn(), +vi.mock("@/lib/stripe/validateGetSubscriptionStatusQuery", () => ({ + validateGetSubscriptionStatusQuery: vi.fn(), })); vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ @@ -36,7 +36,7 @@ describe("getSubscriptionStatusHandler", () => { it("forwards validation error response", async () => { const denied = NextResponse.json({ error: "accountId is required" }, { status: 400 }); - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(denied); + vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue(denied); const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); const res = await getSubscriptionStatusHandler(req); expect(res.status).toBe(400); @@ -44,7 +44,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns { isPro: true } when account subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ id: "sub_1" } as never); vi.mocked(getOrgSubscription).mockResolvedValue(null); vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); @@ -56,7 +56,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns { isPro: true } when only org subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); vi.mocked(getOrgSubscription).mockResolvedValue({ id: "sub_org" } as never); vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); @@ -68,7 +68,7 @@ describe("getSubscriptionStatusHandler", () => { }); it("returns { isPro: false } when neither subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); vi.mocked(getOrgSubscription).mockResolvedValue(null); vi.mocked(isActiveSubscription).mockReturnValue(false); diff --git a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts similarity index 84% rename from lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts rename to lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts index be3ad8053..088e18d1a 100644 --- a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts +++ b/lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ @@ -17,14 +17,14 @@ function getRequest(url: string) { return new NextRequest(url, { headers: { "x-api-key": "test-key" } }); } -describe("validateGetSubscriptionStatusRequest", () => { +describe("validateGetSubscriptionStatusQuery", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns 400 { error: accountId is required } when accountId is missing", async () => { const req = getRequest("http://localhost/api/subscriptions/status"); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateGetSubscriptionStatusQuery(req); expect(res).toBeInstanceOf(NextResponse); expect((res as NextResponse).status).toBe(400); await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); @@ -33,14 +33,14 @@ describe("validateGetSubscriptionStatusRequest", () => { it("returns 400 when accountId is empty string", async () => { const req = getRequest(`http://localhost/api/subscriptions/status?accountId=`); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateGetSubscriptionStatusQuery(req); expect((res as NextResponse).status).toBe(400); await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); }); it("returns 400 for invalid UUID", async () => { const req = getRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateGetSubscriptionStatusQuery(req); expect((res as NextResponse).status).toBe(400); const body = await (res as NextResponse).json(); expect(body.error).toMatch(/accountId must be a valid UUID/i); @@ -54,7 +54,7 @@ describe("validateGetSubscriptionStatusRequest", () => { ), ); const req = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateGetSubscriptionStatusQuery(req); expect((res as NextResponse).status).toBe(401); await expect((res as NextResponse).json()).resolves.toEqual({ error: "Exactly one of x-api-key or Authorization must be provided", @@ -68,7 +68,7 @@ describe("validateGetSubscriptionStatusRequest", () => { authToken: "tok", }); const req = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await validateGetSubscriptionStatusRequest(req); + const res = await validateGetSubscriptionStatusQuery(req); expect(res).toEqual({ accountId: ACCOUNT }); expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ACCOUNT }); }); diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts index 9bc3c5d5b..5618a9586 100644 --- a/lib/stripe/getSubscriptionStatusHandler.ts +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -3,11 +3,11 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; -import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; export async function getSubscriptionStatusHandler(request: NextRequest): Promise { try { - const validated = await validateGetSubscriptionStatusRequest(request); + const validated = await validateGetSubscriptionStatusQuery(request); if (validated instanceof NextResponse) { return validated; } diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateGetSubscriptionStatusQuery.ts similarity index 85% rename from lib/stripe/validateGetSubscriptionStatusRequest.ts rename to lib/stripe/validateGetSubscriptionStatusQuery.ts index 481cee9e8..70092b7ac 100644 --- a/lib/stripe/validateGetSubscriptionStatusRequest.ts +++ b/lib/stripe/validateGetSubscriptionStatusQuery.ts @@ -9,11 +9,11 @@ export const querySchema = z.object({ accountId: z.string().uuid("accountId must be a valid UUID"), }); -export type ValidatedGetSubscriptionStatusRequest = z.infer; +export type ValidatedGetSubscriptionStatusQuery = z.infer; -export async function validateGetSubscriptionStatusRequest( +export async function validateGetSubscriptionStatusQuery( request: NextRequest, -): Promise { +): Promise { const raw = request.nextUrl.searchParams.get("accountId"); if (raw === null || raw === "") { return NextResponse.json( From 78901322192eea5287fa5e485bbe56c9148904c7 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 00:53:42 +0700 Subject: [PATCH 08/13] feat(stripe): enhance getActiveSubscriptions to limit pagination and improve filtering - Introduced a maximum page limit for `subscriptions.list` calls to prevent excessive latency on large accounts. - Updated the function to stop fetching after the first page that contains a matching subscription, optimizing performance. - Added support for an optional `stripeCustomerId` parameter to scope the subscription list to a specific customer. - Enhanced unit tests to cover new functionality, including early termination on matches and pagination limits. --- .../__tests__/getActiveSubscriptions.test.ts | 65 ++++++++++++++++--- lib/stripe/getActiveSubscriptions.ts | 21 +++++- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index a7baecf86..ed2e753c0 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type Stripe from "stripe"; -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import { + getActiveSubscriptions, + MAX_SUBSCRIPTION_LIST_PAGES, +} from "@/lib/stripe/getActiveSubscriptions"; import stripeClient from "@/lib/stripe/client"; vi.mock("@/lib/stripe/client", () => ({ @@ -14,33 +17,77 @@ describe("getActiveSubscriptions", () => { vi.clearAllMocks(); }); - it("collects subscriptions matching accountId across paginated pages", async () => { - const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + it("walks pages until a batch matches accountId", async () => { const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; - const sub2 = { id: "sub_2", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; vi.mocked(stripeClient.subscriptions.list) .mockResolvedValueOnce({ - data: [subOther, sub1], + data: [subOther], has_more: true, } as Stripe.Response>) .mockResolvedValueOnce({ - data: [sub2], - has_more: false, + data: [sub1], + has_more: true, } as Stripe.Response>); const result = await getActiveSubscriptions("acc-a"); expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(2); - expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); + expect(result.map(s => s.id)).toEqual(["sub_1"]); const secondCall = vi.mocked(stripeClient.subscriptions.list).mock.calls[1][0]; expect(secondCall).toMatchObject({ - starting_after: "sub_1", + starting_after: "sub_x", limit: 100, }); }); + it("stops after the first page that includes a match (no further Stripe calls)", async () => { + const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; + const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + const sub2 = { id: "sub_2", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + + vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ + data: [subOther, sub1, sub2], + has_more: true, + } as Stripe.Response>); + + const result = await getActiveSubscriptions("acc-a"); + + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(1); + expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); + }); + + it("passes stripeCustomerId to subscriptions.list when provided", async () => { + const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + + vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ + data: [sub1], + has_more: false, + } as Stripe.Response>); + + await getActiveSubscriptions("acc-a", "cus_123"); + + expect(stripeClient.subscriptions.list).toHaveBeenCalledWith( + expect.objectContaining({ customer: "cus_123", limit: 100 }), + ); + }); + + it(`returns [] after at most ${MAX_SUBSCRIPTION_LIST_PAGES} pages when nothing matches`, async () => { + for (let i = 0; i < MAX_SUBSCRIPTION_LIST_PAGES; i++) { + vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ + data: [{ id: `sub_${i}`, metadata: { accountId: "other" } } as Stripe.Subscription], + has_more: true, + } as Stripe.Response>); + } + + const result = await getActiveSubscriptions("acc-a"); + + expect(result).toEqual([]); + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(MAX_SUBSCRIPTION_LIST_PAGES); + }); + it("returns [] when Stripe throws", async () => { vi.mocked(stripeClient.subscriptions.list).mockRejectedValue(new Error("stripe error")); await expect(getActiveSubscriptions("acc-a")).resolves.toEqual([]); diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index e9ce9415c..1fe2f9644 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -3,18 +3,31 @@ import Stripe from "stripe"; const PAGE_LIMIT = 100; -export const getActiveSubscriptions = async (accountId: string) => { +/** Caps sequential `subscriptions.list` calls to avoid runaway latency on large Stripe accounts. */ +export const MAX_SUBSCRIPTION_LIST_PAGES = 50; + +/** + * Lists active subscriptions whose `metadata.accountId` matches. + * Stops after the first page that yields a match (callers only need one). + * When `stripeCustomerId` is set, scopes the Stripe list to that customer. + */ +export const getActiveSubscriptions = async (accountId: string, stripeCustomerId?: string) => { try { const now = Math.floor(Date.now() / 1000); const activeSubscriptions: Stripe.Subscription[] = []; let startingAfter: string | undefined; let hasMore = true; + let pageIndex = 0; - while (hasMore) { + while (hasMore && pageIndex < MAX_SUBSCRIPTION_LIST_PAGES) { + pageIndex += 1; const listParams: Stripe.SubscriptionListParams = { limit: PAGE_LIMIT, current_period_end: { gt: now }, }; + if (stripeCustomerId) { + listParams.customer = stripeCustomerId; + } if (startingAfter) { listParams.starting_after = startingAfter; } @@ -25,6 +38,10 @@ export const getActiveSubscriptions = async (accountId: string) => { ...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; From 5bdd3a8f26dc16ed3b12af83e79d91c2a6a95c0c Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 01:05:24 +0700 Subject: [PATCH 09/13] refactor(stripe): remove pagination limit in getActiveSubscriptions for improved match retrieval - Eliminated the fixed page limit for `subscriptions.list` calls, allowing the function to paginate until all matches are found. - Updated the logic to break pagination if the cursor does not advance, preventing infinite loops. - Enhanced unit tests to verify behavior with no artificial page limits and to ensure correct handling of pagination scenarios. --- .../__tests__/getActiveSubscriptions.test.ts | 50 ++++++++++++++++--- lib/stripe/getActiveSubscriptions.ts | 9 ++-- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index ed2e753c0..fed5498f6 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type Stripe from "stripe"; -import { - getActiveSubscriptions, - MAX_SUBSCRIPTION_LIST_PAGES, -} from "@/lib/stripe/getActiveSubscriptions"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; import stripeClient from "@/lib/stripe/client"; vi.mock("@/lib/stripe/client", () => ({ @@ -43,6 +40,25 @@ describe("getActiveSubscriptions", () => { }); }); + it("finds a match on a later page (no artificial page limit)", async () => { + const subLate = { id: "sub_late", metadata: { accountId: "acc-a" } } as Stripe.Subscription; + for (let i = 0; i < 52; i++) { + vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ + data: [{ id: `sub_${i}`, metadata: { accountId: "other" } } as Stripe.Subscription], + has_more: true, + } as Stripe.Response>); + } + vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ + data: [subLate], + has_more: false, + } as Stripe.Response>); + + const result = await getActiveSubscriptions("acc-a"); + + expect(result.map(s => s.id)).toEqual(["sub_late"]); + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(53); + }); + it("stops after the first page that includes a match (no further Stripe calls)", async () => { const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; @@ -74,18 +90,36 @@ describe("getActiveSubscriptions", () => { ); }); - it(`returns [] after at most ${MAX_SUBSCRIPTION_LIST_PAGES} pages when nothing matches`, async () => { - for (let i = 0; i < MAX_SUBSCRIPTION_LIST_PAGES; i++) { + it("returns [] when nothing matches after Stripe exhausts pages", async () => { + for (let i = 0; i < 3; i++) { vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ data: [{ id: `sub_${i}`, metadata: { accountId: "other" } } as Stripe.Subscription], - has_more: true, + has_more: i < 2, } as Stripe.Response>); } const result = await getActiveSubscriptions("acc-a"); expect(result).toEqual([]); - expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(MAX_SUBSCRIPTION_LIST_PAGES); + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(3); + }); + + it("breaks if pagination cursor does not advance", async () => { + const stuck = { id: "sub_stuck", metadata: { accountId: "other" } } as Stripe.Subscription; + vi.mocked(stripeClient.subscriptions.list) + .mockResolvedValueOnce({ + data: [stuck], + has_more: true, + } as Stripe.Response>) + .mockResolvedValueOnce({ + data: [stuck], + has_more: true, + } as Stripe.Response>); + + const result = await getActiveSubscriptions("acc-a"); + + expect(result).toEqual([]); + expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(2); }); it("returns [] when Stripe throws", async () => { diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 1fe2f9644..ff807caf5 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -3,13 +3,11 @@ import Stripe from "stripe"; const PAGE_LIMIT = 100; -/** Caps sequential `subscriptions.list` calls to avoid runaway latency on large Stripe accounts. */ -export const MAX_SUBSCRIPTION_LIST_PAGES = 50; - /** * Lists active subscriptions whose `metadata.accountId` matches. * Stops after the first page that yields a match (callers only need one). * When `stripeCustomerId` is set, scopes the Stripe list to that customer. + * Paginates until Stripe reports no more pages (no fixed page cap — avoids missing matches deep in the list). */ export const getActiveSubscriptions = async (accountId: string, stripeCustomerId?: string) => { try { @@ -17,10 +15,8 @@ export const getActiveSubscriptions = async (accountId: string, stripeCustomerId const activeSubscriptions: Stripe.Subscription[] = []; let startingAfter: string | undefined; let hasMore = true; - let pageIndex = 0; - while (hasMore && pageIndex < MAX_SUBSCRIPTION_LIST_PAGES) { - pageIndex += 1; + while (hasMore) { const listParams: Stripe.SubscriptionListParams = { limit: PAGE_LIMIT, current_period_end: { gt: now }, @@ -45,6 +41,7 @@ export const getActiveSubscriptions = async (accountId: string, stripeCustomerId hasMore = page.has_more; const lastId = page.data.at(-1)?.id; if (!lastId) break; + if (startingAfter !== undefined && lastId === startingAfter) break; startingAfter = lastId; } From ccf722cd51705511c2724f83c49fa4b9d9f4efb4 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 01:15:58 +0700 Subject: [PATCH 10/13] refactor(tests): streamline getActiveSubscriptions tests and improve mock handling - Simplified test setup by consolidating mock definitions for `stripeClient.subscriptions.list`. - Enhanced readability and maintainability of tests by using helper functions for mock data generation. - Ensured consistent behavior across tests by standardizing the way mock responses are defined and utilized. --- .../__tests__/getActiveSubscriptions.test.ts | 127 +++++------------- .../getActiveSubscriptionsTestHelpers.ts | 14 ++ 2 files changed, 50 insertions(+), 91 deletions(-) create mode 100644 lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index fed5498f6..3e79e695f 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -1,129 +1,74 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type Stripe from "stripe"; import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; import stripeClient from "@/lib/stripe/client"; +import { ACC, sub, apiList } from "./getActiveSubscriptionsTestHelpers"; vi.mock("@/lib/stripe/client", () => ({ - default: { - subscriptions: { list: vi.fn() }, - }, + default: { subscriptions: { list: vi.fn() } }, })); +const list = () => vi.mocked(stripeClient.subscriptions.list); + describe("getActiveSubscriptions", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => vi.clearAllMocks()); it("walks pages until a batch matches accountId", async () => { - const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; - const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; - - vi.mocked(stripeClient.subscriptions.list) - .mockResolvedValueOnce({ - data: [subOther], - has_more: true, - } as Stripe.Response>) - .mockResolvedValueOnce({ - data: [sub1], - has_more: true, - } as Stripe.Response>); - - const result = await getActiveSubscriptions("acc-a"); - - expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(2); + 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"]); - - const secondCall = vi.mocked(stripeClient.subscriptions.list).mock.calls[1][0]; - expect(secondCall).toMatchObject({ - starting_after: "sub_x", - limit: 100, - }); + 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 () => { - const subLate = { id: "sub_late", metadata: { accountId: "acc-a" } } as Stripe.Subscription; - for (let i = 0; i < 52; i++) { - vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ - data: [{ id: `sub_${i}`, metadata: { accountId: "other" } } as Stripe.Subscription], - has_more: true, - } as Stripe.Response>); - } - vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ - data: [subLate], - has_more: false, - } as Stripe.Response>); - - const result = await getActiveSubscriptions("acc-a"); - + 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(stripeClient.subscriptions.list).toHaveBeenCalledTimes(53); + expect(list()).toHaveBeenCalledTimes(53); }); - it("stops after the first page that includes a match (no further Stripe calls)", async () => { - const subOther = { id: "sub_x", metadata: { accountId: "other" } } as Stripe.Subscription; - const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; - const sub2 = { id: "sub_2", metadata: { accountId: "acc-a" } } as Stripe.Subscription; - - vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ - data: [subOther, sub1, sub2], - has_more: true, - } as Stripe.Response>); - - const result = await getActiveSubscriptions("acc-a"); - - expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(1); + 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("passes stripeCustomerId to subscriptions.list when provided", async () => { - const sub1 = { id: "sub_1", metadata: { accountId: "acc-a" } } as Stripe.Subscription; - - vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ - data: [sub1], - has_more: false, - } as Stripe.Response>); - - await getActiveSubscriptions("acc-a", "cus_123"); - - expect(stripeClient.subscriptions.list).toHaveBeenCalledWith( + list().mockResolvedValueOnce(apiList([sub("sub_1", ACC)], false)); + await getActiveSubscriptions(ACC, "cus_123"); + expect(list()).toHaveBeenCalledWith( expect.objectContaining({ customer: "cus_123", limit: 100 }), ); }); it("returns [] when nothing matches after Stripe exhausts pages", async () => { for (let i = 0; i < 3; i++) { - vi.mocked(stripeClient.subscriptions.list).mockResolvedValueOnce({ - data: [{ id: `sub_${i}`, metadata: { accountId: "other" } } as Stripe.Subscription], - has_more: i < 2, - } as Stripe.Response>); + list().mockResolvedValueOnce(apiList([sub(`sub_${i}`, "other")], i < 2)); } - - const result = await getActiveSubscriptions("acc-a"); - + const result = await getActiveSubscriptions(ACC); expect(result).toEqual([]); - expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(3); + expect(list()).toHaveBeenCalledTimes(3); }); it("breaks if pagination cursor does not advance", async () => { - const stuck = { id: "sub_stuck", metadata: { accountId: "other" } } as Stripe.Subscription; - vi.mocked(stripeClient.subscriptions.list) - .mockResolvedValueOnce({ - data: [stuck], - has_more: true, - } as Stripe.Response>) - .mockResolvedValueOnce({ - data: [stuck], - has_more: true, - } as Stripe.Response>); - - const result = await getActiveSubscriptions("acc-a"); - + const s = sub("sub_stuck", "other"); + list() + .mockResolvedValueOnce(apiList([s], true)) + .mockResolvedValueOnce(apiList([s], true)); + const result = await getActiveSubscriptions(ACC); expect(result).toEqual([]); - expect(stripeClient.subscriptions.list).toHaveBeenCalledTimes(2); + expect(list()).toHaveBeenCalledTimes(2); }); it("returns [] when Stripe throws", async () => { - vi.mocked(stripeClient.subscriptions.list).mockRejectedValue(new Error("stripe error")); - await expect(getActiveSubscriptions("acc-a")).resolves.toEqual([]); + 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..9099eb60a --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts @@ -0,0 +1,14 @@ +import type Stripe from "stripe"; + +export const ACC = "acc-a"; + +export function sub(id: string, accountId: string): Stripe.Subscription { + return { id, metadata: { accountId } } as Stripe.Subscription; +} + +export function apiList( + data: Stripe.Subscription[], + hasMore: boolean, +): Stripe.Response> { + return { data, has_more: hasMore } as Stripe.Response>; +} From a9075a58891ea7616cb101a38f2acc97ce22ceb0 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 4 May 2026 01:21:19 +0700 Subject: [PATCH 11/13] refactor(tests): restructure getActiveSubscriptions test helpers for improved clarity - Consolidated subscription-related helper functions into a single `getActiveSubscriptionsTestHelpers` function. - Enhanced test readability by using destructured imports for mock data generation. - Improved maintainability of tests by centralizing mock definitions and reducing redundancy. --- .../__tests__/getActiveSubscriptions.test.ts | 7 +++++- .../getActiveSubscriptionsTestHelpers.ts | 22 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index 3e79e695f..eecee0552 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; import stripeClient from "@/lib/stripe/client"; -import { ACC, sub, apiList } from "./getActiveSubscriptionsTestHelpers"; +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", () => { diff --git a/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts index 9099eb60a..08e11bb89 100644 --- a/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts +++ b/lib/stripe/__tests__/getActiveSubscriptionsTestHelpers.ts @@ -1,14 +1,18 @@ import type Stripe from "stripe"; -export const ACC = "acc-a"; +export function getActiveSubscriptionsTestHelpers() { + const testAccountId = "acc-a"; -export function sub(id: string, accountId: string): Stripe.Subscription { - return { id, metadata: { accountId } } as Stripe.Subscription; -} + 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>; + } -export function apiList( - data: Stripe.Subscription[], - hasMore: boolean, -): Stripe.Response> { - return { data, has_more: hasMore } as Stripe.Response>; + return { testAccountId, subscription, subscriptionListPage }; } From 37032cf56f9cd86bc0eb405f2777a277cdaa6c55 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 6 May 2026 08:45:33 -0500 Subject: [PATCH 12/13] refactor(api): move subscription status to GET /api/accounts/{id}/subscription Aligns the implementation with recoupable/docs#183: documents subscription status as a resource nested under the account it belongs to, identifies the account via path param, and returns the documented response shape. - New route: app/api/accounts/[id]/subscription - New response: { isPro, status, plan, source } (was { isPro }) - New handler/validator/mapper with unit tests covering account-active, org-active, neither-active, trialing-with-canceled_at, and unsupported Stripe statuses - Deletes the old query-param endpoint and helpers --- .../[id]/subscription/__tests__/route.test.ts | 46 +++++++++ .../subscription}/__tests__/routeTestMocks.ts | 4 +- app/api/accounts/[id]/subscription/route.ts | 35 +++++++ .../status/__tests__/route.test.ts | 36 ------- app/api/subscriptions/status/route.ts | 29 ------ .../buildSubscriptionResponse.test.ts | 69 +++++++++++++ .../getAccountSubscriptionHandler.test.ts | 98 +++++++++++++++++++ .../getSubscriptionStatusHandler.test.ts | 81 --------------- .../validateAccountSubscriptionParams.test.ts | 56 +++++++++++ ...validateGetSubscriptionStatusQuery.test.ts | 75 -------------- lib/stripe/buildSubscriptionResponse.ts | 59 +++++++++++ lib/stripe/getAccountSubscriptionHandler.ts | 42 ++++++++ lib/stripe/getSubscriptionStatusHandler.ts | 31 ------ .../validateAccountSubscriptionParams.ts | 31 ++++++ .../validateGetSubscriptionStatusQuery.ts | 37 ------- 15 files changed, 438 insertions(+), 291 deletions(-) create mode 100644 app/api/accounts/[id]/subscription/__tests__/route.test.ts rename app/api/{subscriptions/status => accounts/[id]/subscription}/__tests__/routeTestMocks.ts (61%) create mode 100644 app/api/accounts/[id]/subscription/route.ts delete mode 100644 app/api/subscriptions/status/__tests__/route.test.ts delete mode 100644 app/api/subscriptions/status/route.ts create mode 100644 lib/stripe/__tests__/buildSubscriptionResponse.test.ts create mode 100644 lib/stripe/__tests__/getAccountSubscriptionHandler.test.ts delete mode 100644 lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts create mode 100644 lib/stripe/__tests__/validateAccountSubscriptionParams.test.ts delete mode 100644 lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts create mode 100644 lib/stripe/buildSubscriptionResponse.ts create mode 100644 lib/stripe/getAccountSubscriptionHandler.ts delete mode 100644 lib/stripe/getSubscriptionStatusHandler.ts create mode 100644 lib/stripe/validateAccountSubscriptionParams.ts delete mode 100644 lib/stripe/validateGetSubscriptionStatusQuery.ts 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/subscriptions/status/__tests__/routeTestMocks.ts b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts similarity index 61% rename from app/api/subscriptions/status/__tests__/routeTestMocks.ts rename to app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts index 117219b00..b9d9a2994 100644 --- a/app/api/subscriptions/status/__tests__/routeTestMocks.ts +++ b/app/api/accounts/[id]/subscription/__tests__/routeTestMocks.ts @@ -4,6 +4,6 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/stripe/getSubscriptionStatusHandler", () => ({ - getSubscriptionStatusHandler: vi.fn(), +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/app/api/subscriptions/status/__tests__/route.test.ts b/app/api/subscriptions/status/__tests__/route.test.ts deleted file mode 100644 index 25bc1e971..000000000 --- a/app/api/subscriptions/status/__tests__/route.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import "./routeTestMocks"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; - -const { GET, OPTIONS } = await import("../route"); - -const ACCOUNT_ID = "123e4567-e89b-12d3-a456-426614174000"; - -describe("app/api/subscriptions/status/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 forwards the request to getSubscriptionStatusHandler and returns its response", async () => { - const handlerRes = NextResponse.json({ isPro: true }, { status: 200 }); - vi.mocked(getSubscriptionStatusHandler).mockResolvedValue(handlerRes); - - const url = `http://localhost/api/subscriptions/status?accountId=${ACCOUNT_ID}`; - const req = new NextRequest(url, { headers: { "x-api-key": "test-key" } }); - const res = await GET(req); - - expect(getSubscriptionStatusHandler).toHaveBeenCalledTimes(1); - expect(getSubscriptionStatusHandler).toHaveBeenCalledWith(req); - expect(res).toBe(handlerRes); - await expect(res.json()).resolves.toEqual({ isPro: true }); - }); -}); diff --git a/app/api/subscriptions/status/route.ts b/app/api/subscriptions/status/route.ts deleted file mode 100644 index ce8fecbb5..000000000 --- a/app/api/subscriptions/status/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * GET /api/subscriptions/status: returns whether the account has active paid access (direct or via organization). - * - * @param request - The incoming HTTP request (query `accountId` required). - * @returns JSON `{ isPro }` or an `{ error }` body with 4xx status. - */ -export async function GET(request: NextRequest) { - return getSubscriptionStatusHandler(request); -} - -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__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts deleted file mode 100644 index fb1f5536f..000000000 --- a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; - -import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; -import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; -import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; -import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/stripe/validateGetSubscriptionStatusQuery", () => ({ - validateGetSubscriptionStatusQuery: vi.fn(), -})); - -vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ - getActiveSubscriptionDetails: vi.fn(), -})); - -vi.mock("@/lib/stripe/getOrgSubscription", () => ({ - getOrgSubscription: vi.fn(), -})); - -vi.mock("@/lib/stripe/isActiveSubscription", () => ({ - default: vi.fn(), -})); - -const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; - -describe("getSubscriptionStatusHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("forwards validation error response", async () => { - const denied = NextResponse.json({ error: "accountId is required" }, { status: 400 }); - vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue(denied); - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await getSubscriptionStatusHandler(req); - expect(res.status).toBe(400); - expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); - }); - - it("returns { isPro: true } when account subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ id: "sub_1" } as never); - vi.mocked(getOrgSubscription).mockResolvedValue(null); - vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); - - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await getSubscriptionStatusHandler(req); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual({ isPro: true }); - }); - - it("returns { isPro: true } when only org subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue({ id: "sub_org" } as never); - vi.mocked(isActiveSubscription).mockImplementation(sub => !!sub); - - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await getSubscriptionStatusHandler(req); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual({ isPro: true }); - }); - - it("returns { isPro: false } when neither subscription is active", async () => { - vi.mocked(validateGetSubscriptionStatusQuery).mockResolvedValue({ accountId: ACCOUNT }); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue(null); - vi.mocked(isActiveSubscription).mockReturnValue(false); - - const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await getSubscriptionStatusHandler(req); - expect(res.status).toBe(200); - await expect(res.json()).resolves.toEqual({ isPro: false }); - }); -}); 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/__tests__/validateGetSubscriptionStatusQuery.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts deleted file mode 100644 index 088e18d1a..000000000 --- a/lib/stripe/__tests__/validateGetSubscriptionStatusQuery.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; -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"; - -function getRequest(url: string) { - return new NextRequest(url, { headers: { "x-api-key": "test-key" } }); -} - -describe("validateGetSubscriptionStatusQuery", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 400 { error: accountId is required } when accountId is missing", async () => { - const req = getRequest("http://localhost/api/subscriptions/status"); - const res = await validateGetSubscriptionStatusQuery(req); - expect(res).toBeInstanceOf(NextResponse); - expect((res as NextResponse).status).toBe(400); - await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); - expect(validateAuthContext).not.toHaveBeenCalled(); - }); - - it("returns 400 when accountId is empty string", async () => { - const req = getRequest(`http://localhost/api/subscriptions/status?accountId=`); - const res = await validateGetSubscriptionStatusQuery(req); - expect((res as NextResponse).status).toBe(400); - await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); - }); - - it("returns 400 for invalid UUID", async () => { - const req = getRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`); - const res = await validateGetSubscriptionStatusQuery(req); - expect((res as NextResponse).status).toBe(400); - const body = await (res as NextResponse).json(); - expect(body.error).toMatch(/accountId must be a valid UUID/i); - }); - - 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 = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await validateGetSubscriptionStatusQuery(req); - expect((res as NextResponse).status).toBe(401); - await expect((res as NextResponse).json()).resolves.toEqual({ - error: "Exactly one of x-api-key or Authorization must be provided", - }); - }); - - it("returns accountId when auth succeeds", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: ACCOUNT, - orgId: null, - authToken: "tok", - }); - const req = getRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`); - const res = await validateGetSubscriptionStatusQuery(req); - expect(res).toEqual({ accountId: ACCOUNT }); - expect(validateAuthContext).toHaveBeenCalledWith(req, { accountId: ACCOUNT }); - }); -}); diff --git a/lib/stripe/buildSubscriptionResponse.ts b/lib/stripe/buildSubscriptionResponse.ts new file mode 100644 index 000000000..4fb9a095e --- /dev/null +++ b/lib/stripe/buildSubscriptionResponse.ts @@ -0,0 +1,59 @@ +import type Stripe from "stripe"; +import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; + +export type SubscriptionSource = "account" | "organization"; +export type SubscriptionStatus = "active" | "trialing" | "canceled" | "past_due" | "none"; + +export interface SubscriptionResponse { + isPro: boolean; + status: SubscriptionStatus; + plan: string | null; + source: SubscriptionSource | null; +} + +const SUPPORTED_STATUSES = new Set([ + "active", + "trialing", + "canceled", + "past_due", +]); + +function toStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { + return SUPPORTED_STATUSES.has(stripeStatus as SubscriptionStatus) + ? (stripeStatus as SubscriptionStatus) + : "none"; +} + +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/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts deleted file mode 100644 index 5618a9586..000000000 --- a/lib/stripe/getSubscriptionStatusHandler.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 isActiveSubscription from "@/lib/stripe/isActiveSubscription"; -import { validateGetSubscriptionStatusQuery } from "@/lib/stripe/validateGetSubscriptionStatusQuery"; - -export async function getSubscriptionStatusHandler(request: NextRequest): Promise { - try { - const validated = await validateGetSubscriptionStatusQuery(request); - if (validated instanceof NextResponse) { - return validated; - } - - const [accountSubscription, orgSubscription] = await Promise.all([ - getActiveSubscriptionDetails(validated.accountId), - getOrgSubscription(validated.accountId), - ]); - - const isPro = - isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); - - return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); - } catch (error) { - console.error("[getSubscriptionStatusHandler]", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500, headers: getCorsHeaders() }, - ); - } -} 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; +} diff --git a/lib/stripe/validateGetSubscriptionStatusQuery.ts b/lib/stripe/validateGetSubscriptionStatusQuery.ts deleted file mode 100644 index 70092b7ac..000000000 --- a/lib/stripe/validateGetSubscriptionStatusQuery.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NextRequest } from "next/server"; -import { 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 querySchema = z.object({ - accountId: z.string().uuid("accountId must be a valid UUID"), -}); - -export type ValidatedGetSubscriptionStatusQuery = z.infer; - -export async function validateGetSubscriptionStatusQuery( - request: NextRequest, -): Promise { - const raw = request.nextUrl.searchParams.get("accountId"); - if (raw === null || raw === "") { - return NextResponse.json( - { error: "accountId is required" }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const parsed = querySchema.safeParse({ accountId: raw }); - if (!parsed.success) { - const first = parsed.error.issues[0]; - return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); - } - - const authContext = await validateAuthContext(request, { accountId: parsed.data.accountId }); - if (authContext instanceof NextResponse) { - return mapToSubscriptionSessionError(authContext); - } - - return { accountId: authContext.accountId }; -} From c1ebf26577fad1ae6ad609c3c85d6a6cf58bab9f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 6 May 2026 08:51:58 -0500 Subject: [PATCH 13/13] refactor(stripe): drop unused stripeCustomerId; extract toStatus to its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YAGNI: getActiveSubscriptions no longer accepts the unused stripeCustomerId parameter; corresponding test removed. - SRP: toStatus (Stripe status → SubscriptionStatus enum) lives in its own module with focused unit tests; buildSubscriptionResponse now imports it. --- .../__tests__/getActiveSubscriptions.test.ts | 8 -------- lib/stripe/__tests__/toStatus.test.ts | 19 ++++++++++++++++++ lib/stripe/buildSubscriptionResponse.ts | 16 ++------------- lib/stripe/getActiveSubscriptions.ts | 6 +----- lib/stripe/toStatus.ts | 20 +++++++++++++++++++ 5 files changed, 42 insertions(+), 27 deletions(-) create mode 100644 lib/stripe/__tests__/toStatus.test.ts create mode 100644 lib/stripe/toStatus.ts diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts index eecee0552..a352d8ee8 100644 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -45,14 +45,6 @@ describe("getActiveSubscriptions", () => { expect(result.map(s => s.id)).toEqual(["sub_1", "sub_2"]); }); - it("passes stripeCustomerId to subscriptions.list when provided", async () => { - list().mockResolvedValueOnce(apiList([sub("sub_1", ACC)], false)); - await getActiveSubscriptions(ACC, "cus_123"); - expect(list()).toHaveBeenCalledWith( - expect.objectContaining({ customer: "cus_123", limit: 100 }), - ); - }); - 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)); 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/buildSubscriptionResponse.ts b/lib/stripe/buildSubscriptionResponse.ts index 4fb9a095e..12b99031d 100644 --- a/lib/stripe/buildSubscriptionResponse.ts +++ b/lib/stripe/buildSubscriptionResponse.ts @@ -1,8 +1,9 @@ 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 = "active" | "trialing" | "canceled" | "past_due" | "none"; +export type { SubscriptionStatus }; export interface SubscriptionResponse { isPro: boolean; @@ -11,19 +12,6 @@ export interface SubscriptionResponse { source: SubscriptionSource | null; } -const SUPPORTED_STATUSES = new Set([ - "active", - "trialing", - "canceled", - "past_due", -]); - -function toStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { - return SUPPORTED_STATUSES.has(stripeStatus as SubscriptionStatus) - ? (stripeStatus as SubscriptionStatus) - : "none"; -} - const inactive: SubscriptionResponse = { isPro: false, status: "none", diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index ff807caf5..be0c603f8 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -6,10 +6,9 @@ const PAGE_LIMIT = 100; /** * Lists active subscriptions whose `metadata.accountId` matches. * Stops after the first page that yields a match (callers only need one). - * When `stripeCustomerId` is set, scopes the Stripe list to that customer. * Paginates until Stripe reports no more pages (no fixed page cap — avoids missing matches deep in the list). */ -export const getActiveSubscriptions = async (accountId: string, stripeCustomerId?: string) => { +export const getActiveSubscriptions = async (accountId: string) => { try { const now = Math.floor(Date.now() / 1000); const activeSubscriptions: Stripe.Subscription[] = []; @@ -21,9 +20,6 @@ export const getActiveSubscriptions = async (accountId: string, stripeCustomerId limit: PAGE_LIMIT, current_period_end: { gt: now }, }; - if (stripeCustomerId) { - listParams.customer = stripeCustomerId; - } if (startingAfter) { listParams.starting_after = startingAfter; } 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"; +}