From aeaf9b9641aef7cae5f23c4095a53a0baba5658d Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 03:41:36 +0530 Subject: [PATCH 1/4] feat(api): migrate GET /api/subscriptions to /api/accounts/{id}/subscription Ports the Express /api/subscriptions?accountId= handler to a nested /api/accounts/{id}/subscription route with validateAuthContext + canAccessAccount access check. Seeds lib/stripe/* and lib/enterprise/*, adds the subscriptions domain, and pins the Stripe SDK apiVersion to match chat. Response body is byte-identical to the legacy endpoint. --- app/api/accounts/[id]/subscription/route.ts | 34 +++++ lib/consts.ts | 18 +++ lib/enterprise/isEnterprise.ts | 15 +++ .../__tests__/getActiveSubscriptions.test.ts | 67 ++++++++++ lib/stripe/client.ts | 25 ++++ lib/stripe/getActiveSubscriptionDetails.ts | 20 +++ lib/stripe/getActiveSubscriptions.ts | 32 +++++ .../__tests__/getSubscriptionsHandler.test.ts | 120 +++++++++++++++++ .../validateGetSubscriptionRequest.test.ts | 125 ++++++++++++++++++ lib/subscriptions/getSubscriptionsHandler.ts | 68 ++++++++++ .../validateGetSubscriptionRequest.ts | 68 ++++++++++ package.json | 1 + pnpm-lock.yaml | 12 ++ 13 files changed, 605 insertions(+) create mode 100644 app/api/accounts/[id]/subscription/route.ts create mode 100644 lib/enterprise/isEnterprise.ts create mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts create mode 100644 lib/stripe/client.ts create mode 100644 lib/stripe/getActiveSubscriptionDetails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts create mode 100644 lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts create mode 100644 lib/subscriptions/getSubscriptionsHandler.ts create mode 100644 lib/subscriptions/validateGetSubscriptionRequest.ts diff --git a/app/api/accounts/[id]/subscription/route.ts b/app/api/accounts/[id]/subscription/route.ts new file mode 100644 index 000000000..4f1fd3c7d --- /dev/null +++ b/app/api/accounts/[id]/subscription/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionsHandler } from "@/lib/subscriptions/getSubscriptionsHandler"; + +/** + * 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/accounts/{id}/subscription + * + * Returns the account's active Stripe subscription, or a short-circuit + * `{ isEnterprise: true }` for enterprise-domain accounts. + * + * @param request - The request object + * @param options - Route options containing params + * @param options.params - Route params containing the account ID + * @returns A NextResponse with `{ status, subscription }` or `{ status, isEnterprise }` + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return getSubscriptionsHandler(request, options.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/consts.ts b/lib/consts.ts index 388428d85..4441de323 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -6,3 +6,21 @@ export * from "./const"; // Additional constants needed for evals that are specific to Recoup-Chat export const NEW_API_BASE_URL = "https://recoup-api.vercel.app"; + +/** + * Email domains that grant enterprise-tier access. + * + * Used by `isEnterprise()` to short-circuit the Stripe lookup in + * `GET /api/accounts/{id}/subscription`: accounts whose emails match any + * of these domains are treated as paid without needing an active Stripe + * subscription. Ported verbatim from the legacy Express service to + * preserve behaviour. + */ +export const ENTERPRISE_DOMAINS: ReadonlySet = new Set([ + "recoupable.com", + "rostrum.com", + "spaceheatermusic.io", + "fatbeats.com", + "cantorarecords.net", + "rostrumrecords.com", +]); diff --git a/lib/enterprise/isEnterprise.ts b/lib/enterprise/isEnterprise.ts new file mode 100644 index 000000000..34ea0ce92 --- /dev/null +++ b/lib/enterprise/isEnterprise.ts @@ -0,0 +1,15 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/consts"; +import { extractDomain } from "@/lib/email/extractDomain"; + +/** + * Returns true when the email's domain is on the enterprise allow-list. + * + * Enterprise-tagged accounts bypass the Stripe active-subscription check + * in `GET /api/accounts/{id}/subscription`. + */ +export function isEnterprise(email: string): boolean { + if (!email) return false; + const domain = extractDomain(email); + if (!domain) return false; + return ENTERPRISE_DOMAINS.has(domain); +} diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts new file mode 100644 index 000000000..5453cb537 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscriptions.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import stripeClient from "@/lib/stripe/client"; +import { getActiveSubscriptions } from "../getActiveSubscriptions"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { + subscriptions: { list: vi.fn() }, + }, +})); + +const listMock = vi.mocked(stripeClient.subscriptions.list); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const otherId = "660e8400-e29b-41d4-a716-446655440000"; + +describe("getActiveSubscriptions", () => { + beforeEach(() => { + listMock.mockReset(); + }); + + it("calls Stripe with limit 100 and a current_period_end.gt filter", async () => { + listMock.mockResolvedValue({ data: [] }); + + await getActiveSubscriptions(accountId); + + expect(listMock).toHaveBeenCalledTimes(1); + const arg = listMock.mock.calls[0][0]; + expect(arg.limit).toBe(100); + expect(typeof arg.current_period_end.gt).toBe("number"); + }); + + it("filters by metadata.accountId when provided", async () => { + listMock.mockResolvedValue({ + data: [ + { id: "sub_a", metadata: { accountId } }, + { id: "sub_b", metadata: { accountId: otherId } }, + { id: "sub_c", metadata: {} }, + ], + }); + + const result = await getActiveSubscriptions(accountId); + + expect(result.map(s => s.id)).toEqual(["sub_a"]); + }); + + it("returns all subscriptions when no accountId is provided", async () => { + listMock.mockResolvedValue({ + data: [ + { id: "sub_a", metadata: { accountId } }, + { id: "sub_b", metadata: { accountId: otherId } }, + ], + }); + + const result = await getActiveSubscriptions(); + + expect(result.map(s => s.id)).toEqual(["sub_a", "sub_b"]); + }); + + it("returns [] when Stripe throws, never propagates the error", async () => { + listMock.mockRejectedValue(new Error("stripe 500")); + + const result = await getActiveSubscriptions(accountId); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts new file mode 100644 index 000000000..80dcf0c95 --- /dev/null +++ b/lib/stripe/client.ts @@ -0,0 +1,25 @@ +import Stripe from "stripe"; + +/** + * Pinned Stripe API version. Matches the default version shipped with + * `stripe@17.x` (the version used by `mono/chat`), so this service and + * the chat app talk to the same Stripe schema. + */ +const STRIPE_API_VERSION = "2024-10-28.acacia" as const; + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK; + +// Fail-closed in production: a missing secret means every subscription +// lookup would silently 500 at first Stripe call. Outside production we +// let module-load succeed so tests and local builds don't break. +if (!stripeSecretKey && process.env.NODE_ENV === "production") { + throw new Error( + "Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.", + ); +} + +const stripeClient = new Stripe(stripeSecretKey ?? "sk_test_unset", { + apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, +}); + +export default stripeClient; diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..0365ed705 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,20 @@ +import Stripe from "stripe"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; + +/** + * Returns the first active Stripe subscription for an account, or null. + * + * Thin wrapper around `getActiveSubscriptions` for the common "does this + * account have a paid subscription?" question. + */ +export async function getActiveSubscriptionDetails( + accountId: string, +): Promise { + try { + const subs = await getActiveSubscriptions(accountId); + return subs.length > 0 ? subs[0] : null; + } catch (error) { + console.error("[ERROR] getActiveSubscriptionDetails:", error); + return null; + } +} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..cdab7004e --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,32 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Fetch active Stripe subscriptions, optionally filtered by `metadata.accountId`. + * + * Subscriptions are tagged with `metadata.accountId` at checkout time in the + * chat app; this helper is the read side of that contract. The query is + * bounded at `limit: 100` — historically the active Stripe subscription set + * has stayed below this cap, but if it grows past it the filter becomes + * lossy. Flag the cap here rather than silently paginate. + * + * @param accountId - optional account ID to filter by `metadata.accountId` + * @returns array of active subscriptions (empty on error or no match) + */ +export async function getActiveSubscriptions(accountId?: string): Promise { + try { + const nowSec = Math.floor(Date.now() / 1000); + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { gt: nowSec }, + }); + + const data = subscriptions?.data ?? []; + if (!accountId) return data; + + return data.filter(sub => sub.metadata?.accountId === accountId); + } catch (error) { + console.error("[ERROR] getActiveSubscriptions:", error); + return []; + } +} diff --git a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts new file mode 100644 index 000000000..c4150d8a6 --- /dev/null +++ b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getSubscriptionsHandler } from "../getSubscriptionsHandler"; +import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { isEnterprise } from "@/lib/enterprise/isEnterprise"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetSubscriptionRequest", () => ({ + validateGetSubscriptionRequest: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +vi.mock("@/lib/enterprise/isEnterprise", () => ({ + isEnterprise: vi.fn(), +})); + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const makeRequest = () => + new NextRequest(`http://localhost/api/accounts/${accountId}/subscription`, { method: "GET" }); + +describe("getSubscriptionsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the validator error when validation fails", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(err); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + + expect(res).toBe(err); + expect(selectAccountEmails).not.toHaveBeenCalled(); + }); + + it("returns 404 when the account has no emails", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body).toEqual({ status: "error", error: "Account not found" }); + }); + + it("returns 200 isEnterprise for enterprise accounts without calling Stripe", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(selectAccountEmails).mockResolvedValue([ + { email: "someone@recoupable.com" }, + ] as never); + vi.mocked(isEnterprise).mockReturnValue(true); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ status: "success", isEnterprise: true }); + expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + }); + + it("returns 404 when no active subscription exists for a non-enterprise account", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); + vi.mocked(isEnterprise).mockReturnValue(false); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(404); + expect(body).toEqual({ status: "error", error: "No active subscription found" }); + }); + + it("returns 200 with the subscription for a paid account", async () => { + const subscription = { + id: "sub_123", + metadata: { accountId }, + } as never; + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); + vi.mocked(isEnterprise).mockReturnValue(false); + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(subscription); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual({ + status: "success", + subscription: { id: "sub_123", metadata: { accountId } }, + }); + expect(getActiveSubscriptionDetails).toHaveBeenCalledWith(accountId); + }); + + it("returns 500 generic error and never leaks the raw exception message", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(selectAccountEmails).mockRejectedValue( + new Error("db down: connection refused at 10.0.0.1:5432"), + ); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body).toEqual({ status: "error", error: "Internal server error" }); + expect(body.error).not.toContain("db down"); + }); +}); diff --git a/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts new file mode 100644 index 000000000..d3bebb01e --- /dev/null +++ b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const requesterId = "660e8400-e29b-41d4-a716-446655440000"; +const makeRequest = (id = accountId) => + new NextRequest(`http://localhost/api/accounts/${id}/subscription`, { + method: "GET", + headers: { Authorization: "Bearer test-token" }, + }); + +describe("validateGetSubscriptionRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["not-a-uuid", "non-UUID id"], + ["", "empty id"], + ])("returns 400 for %s (%s)", async id => { + const result = await validateGetSubscriptionRequest(makeRequest(), id); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns the auth error when authentication fails", async () => { + const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBe(authError); + expect(selectAccounts).not.toHaveBeenCalled(); + }); + + it("returns 404 when the account does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: requesterId, + authToken: "t", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([]); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(404); + expect(canAccessAccount).not.toHaveBeenCalled(); + }); + + it("returns 403 when the requester cannot access the account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: requesterId, + authToken: "t", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + // Regression: access check must compare the CALLER's account id against + // the target path id — never the path id against itself. + expect(canAccessAccount).toHaveBeenCalledWith({ + currentAccountId: requesterId, + targetAccountId: accountId, + }); + }); + + it("does not pass the target accountId as an override to validateAuthContext", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: requesterId, + authToken: "t", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + await validateGetSubscriptionRequest(makeRequest(), accountId); + + // Regression: passing `{ accountId: id }` here would rewrite + // authResult.accountId to the target, collapsing the access check into + // a self-check that always returns true. + expect(validateAuthContext).toHaveBeenCalledWith(expect.anything()); + const call = vi.mocked(validateAuthContext).mock.calls[0]; + expect(call[1]).toBeUndefined(); + }); + + it("returns the validated accountId on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: requesterId, + authToken: "t", + orgId: null, + }); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toEqual({ accountId }); + }); +}); diff --git a/lib/subscriptions/getSubscriptionsHandler.ts b/lib/subscriptions/getSubscriptionsHandler.ts new file mode 100644 index 000000000..83f96cb97 --- /dev/null +++ b/lib/subscriptions/getSubscriptionsHandler.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { isEnterprise } from "@/lib/enterprise/isEnterprise"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; + +/** + * Handler for GET /api/accounts/{id}/subscription. + * + * Response body mirrors the legacy Express `/api/subscriptions?accountId=...` + * endpoint. The `subscription` field is a raw `Stripe.Subscription` — its + * fields stay camelCase by design (third-party typed payload), even though + * the rest of the API uses snake_case. + * + * @returns 200 with `{ status, subscription }` or `{ status, isEnterprise }`, + * or 400/401/403/404/500 on error. + */ +export async function getSubscriptionsHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validated = await validateGetSubscriptionRequest(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + const { accountId } = validated; + + const accountEmails = await selectAccountEmails({ accountIds: accountId }); + if (!accountEmails || accountEmails.length === 0) { + return NextResponse.json( + { status: "error", error: "Account not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const isAccountEnterprise = accountEmails.some(record => isEnterprise(record.email || "")); + if (isAccountEnterprise) { + return NextResponse.json( + { status: "success", isEnterprise: true }, + { status: 200, headers: getCorsHeaders() }, + ); + } + + const subscription = await getActiveSubscriptionDetails(accountId); + if (!subscription) { + return NextResponse.json( + { status: "error", error: "No active subscription found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "success", subscription }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getSubscriptionsHandler:", error); + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/subscriptions/validateGetSubscriptionRequest.ts b/lib/subscriptions/validateGetSubscriptionRequest.ts new file mode 100644 index 000000000..0e594e073 --- /dev/null +++ b/lib/subscriptions/validateGetSubscriptionRequest.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +export const getSubscriptionParamsSchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID"), +}); + +export type GetSubscriptionParams = z.infer; + +export interface ValidatedGetSubscriptionRequest { + accountId: string; +} + +/** + * Validates GET /api/accounts/{id}/subscription: 400 bad UUID, 401 unauth, + * 404 missing account, 403 no access. The path id is NOT passed as an auth + * override — doing so would rewrite the caller's id to the target and + * collapse the access check into a self-check that always passes. + */ +export async function validateGetSubscriptionRequest( + request: NextRequest, + id: string, +): Promise { + const parsed = getSubscriptionParamsSchema.safeParse({ account_id: id }); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const accountId = parsed.data.account_id; + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const existing = await selectAccounts(accountId); + if (!existing.length) { + return NextResponse.json( + { status: "error", error: "Account not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const hasAccess = await canAccessAccount({ + currentAccountId: authResult.accountId, + targetAccountId: accountId, + }); + if (!hasAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + return { accountId }; +} diff --git a/package.json b/package.json index 5d12b8b07..263dc6147 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-dom": "^19.2.1", "resend": "^6.6.0", "sharp": "^0.34.5", + "stripe": "^17.4.0", "viem": "^2.21.26", "x402-fetch": "^0.7.3", "x402-next": "^0.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72b683bc4..a841ff857 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + stripe: + specifier: ^17.4.0 + version: 17.7.0 viem: specifier: ^2.21.26 version: 2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) @@ -5820,6 +5823,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -14186,6 +14193,11 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@17.7.0: + dependencies: + '@types/node': 20.19.25 + qs: 6.14.0 + styled-jsx@5.1.6(react@19.2.1): dependencies: client-only: 0.0.1 From a2bf08144989bce8ecbc81acd17dcd6a22430c37 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 04:11:27 +0530 Subject: [PATCH 2/4] refactor: scalable stripe search + collapse wrapper + single derived validator type - getActiveSubscriptions (list 100 + client-side filter) -> getActiveSubscription using stripe.subscriptions.search with metadata+status filter (scales past 100) - Drop getActiveSubscriptionDetails wrapper, inline nullable handling - Validator returns derived GetSubscriptionParams directly (drop extra interface) --- .../__tests__/getActiveSubscription.test.ts | 57 ++++++++++++++++ .../__tests__/getActiveSubscriptions.test.ts | 67 ------------------- lib/stripe/getActiveSubscription.ts | 24 +++++++ lib/stripe/getActiveSubscriptionDetails.ts | 20 ------ lib/stripe/getActiveSubscriptions.ts | 32 --------- .../__tests__/getSubscriptionsHandler.test.ts | 30 ++++----- .../validateGetSubscriptionRequest.test.ts | 34 +++------- lib/subscriptions/getSubscriptionsHandler.ts | 47 ++++--------- .../validateGetSubscriptionRequest.ts | 55 +++++---------- 9 files changed, 133 insertions(+), 233 deletions(-) create mode 100644 lib/stripe/__tests__/getActiveSubscription.test.ts delete mode 100644 lib/stripe/__tests__/getActiveSubscriptions.test.ts create mode 100644 lib/stripe/getActiveSubscription.ts delete mode 100644 lib/stripe/getActiveSubscriptionDetails.ts delete mode 100644 lib/stripe/getActiveSubscriptions.ts diff --git a/lib/stripe/__tests__/getActiveSubscription.test.ts b/lib/stripe/__tests__/getActiveSubscription.test.ts new file mode 100644 index 000000000..cd15caba6 --- /dev/null +++ b/lib/stripe/__tests__/getActiveSubscription.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import stripeClient from "@/lib/stripe/client"; +import { getActiveSubscription } from "../getActiveSubscription"; + +vi.mock("@/lib/stripe/client", () => ({ + default: { subscriptions: { search: vi.fn() } }, +})); + +const searchMock = vi.mocked(stripeClient.subscriptions.search); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; + +describe("getActiveSubscription", () => { + beforeEach(() => { + searchMock.mockReset(); + }); + + it("queries Stripe with metadata + active status filter", async () => { + searchMock.mockResolvedValue({ data: [] }); + + await getActiveSubscription(accountId); + + expect(searchMock).toHaveBeenCalledWith({ + query: `status:"active" AND metadata["accountId"]:"${accountId}"`, + limit: 1, + }); + }); + + it("returns the first matching subscription", async () => { + const sub = { id: "sub_1", metadata: { accountId } }; + searchMock.mockResolvedValue({ data: [sub] }); + + const result = await getActiveSubscription(accountId); + + expect(result).toBe(sub); + }); + + it("returns null when no subscription matches", async () => { + searchMock.mockResolvedValue({ data: [] }); + + const result = await getActiveSubscription(accountId); + + expect(result).toBeNull(); + }); + + it("returns null and logs on Stripe error", async () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + searchMock.mockRejectedValue(new Error("stripe down")); + + const result = await getActiveSubscription(accountId); + + expect(result).toBeNull(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/lib/stripe/__tests__/getActiveSubscriptions.test.ts b/lib/stripe/__tests__/getActiveSubscriptions.test.ts deleted file mode 100644 index 5453cb537..000000000 --- a/lib/stripe/__tests__/getActiveSubscriptions.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -import stripeClient from "@/lib/stripe/client"; -import { getActiveSubscriptions } from "../getActiveSubscriptions"; - -vi.mock("@/lib/stripe/client", () => ({ - default: { - subscriptions: { list: vi.fn() }, - }, -})); - -const listMock = vi.mocked(stripeClient.subscriptions.list); - -const accountId = "550e8400-e29b-41d4-a716-446655440000"; -const otherId = "660e8400-e29b-41d4-a716-446655440000"; - -describe("getActiveSubscriptions", () => { - beforeEach(() => { - listMock.mockReset(); - }); - - it("calls Stripe with limit 100 and a current_period_end.gt filter", async () => { - listMock.mockResolvedValue({ data: [] }); - - await getActiveSubscriptions(accountId); - - expect(listMock).toHaveBeenCalledTimes(1); - const arg = listMock.mock.calls[0][0]; - expect(arg.limit).toBe(100); - expect(typeof arg.current_period_end.gt).toBe("number"); - }); - - it("filters by metadata.accountId when provided", async () => { - listMock.mockResolvedValue({ - data: [ - { id: "sub_a", metadata: { accountId } }, - { id: "sub_b", metadata: { accountId: otherId } }, - { id: "sub_c", metadata: {} }, - ], - }); - - const result = await getActiveSubscriptions(accountId); - - expect(result.map(s => s.id)).toEqual(["sub_a"]); - }); - - it("returns all subscriptions when no accountId is provided", async () => { - listMock.mockResolvedValue({ - data: [ - { id: "sub_a", metadata: { accountId } }, - { id: "sub_b", metadata: { accountId: otherId } }, - ], - }); - - const result = await getActiveSubscriptions(); - - expect(result.map(s => s.id)).toEqual(["sub_a", "sub_b"]); - }); - - it("returns [] when Stripe throws, never propagates the error", async () => { - listMock.mockRejectedValue(new Error("stripe 500")); - - const result = await getActiveSubscriptions(accountId); - - expect(result).toEqual([]); - }); -}); diff --git a/lib/stripe/getActiveSubscription.ts b/lib/stripe/getActiveSubscription.ts new file mode 100644 index 000000000..d6c3851be --- /dev/null +++ b/lib/stripe/getActiveSubscription.ts @@ -0,0 +1,24 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Returns the first active Stripe subscription tagged with `metadata.accountId`, + * or null. Uses `subscriptions.search` so the metadata + status filter runs on + * Stripe's side — scales independently of total subscription count (the prior + * `list({ limit: 100 })` + client-side filter was lossy once the active set + * crossed 100). Search is eventually consistent (~1 min lag after writes). + */ +export async function getActiveSubscription( + accountId: string, +): Promise { + try { + const result = await stripeClient.subscriptions.search({ + query: `status:"active" AND metadata["accountId"]:"${accountId}"`, + limit: 1, + }); + return result.data[0] ?? null; + } catch (error) { + console.error("[ERROR] getActiveSubscription:", error); + return null; + } +} diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts deleted file mode 100644 index 0365ed705..000000000 --- a/lib/stripe/getActiveSubscriptionDetails.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Stripe from "stripe"; -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; - -/** - * Returns the first active Stripe subscription for an account, or null. - * - * Thin wrapper around `getActiveSubscriptions` for the common "does this - * account have a paid subscription?" question. - */ -export async function getActiveSubscriptionDetails( - accountId: string, -): Promise { - try { - const subs = await getActiveSubscriptions(accountId); - return subs.length > 0 ? subs[0] : null; - } catch (error) { - console.error("[ERROR] getActiveSubscriptionDetails:", error); - return null; - } -} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts deleted file mode 100644 index cdab7004e..000000000 --- a/lib/stripe/getActiveSubscriptions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import Stripe from "stripe"; -import stripeClient from "@/lib/stripe/client"; - -/** - * Fetch active Stripe subscriptions, optionally filtered by `metadata.accountId`. - * - * Subscriptions are tagged with `metadata.accountId` at checkout time in the - * chat app; this helper is the read side of that contract. The query is - * bounded at `limit: 100` — historically the active Stripe subscription set - * has stayed below this cap, but if it grows past it the filter becomes - * lossy. Flag the cap here rather than silently paginate. - * - * @param accountId - optional account ID to filter by `metadata.accountId` - * @returns array of active subscriptions (empty on error or no match) - */ -export async function getActiveSubscriptions(accountId?: string): Promise { - try { - const nowSec = Math.floor(Date.now() / 1000); - const subscriptions = await stripeClient.subscriptions.list({ - limit: 100, - current_period_end: { gt: nowSec }, - }); - - const data = subscriptions?.data ?? []; - if (!accountId) return data; - - return data.filter(sub => sub.metadata?.accountId === accountId); - } catch (error) { - console.error("[ERROR] getActiveSubscriptions:", error); - return []; - } -} diff --git a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts index c4150d8a6..4014269ad 100644 --- a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts +++ b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts @@ -5,7 +5,7 @@ import { getSubscriptionsHandler } from "../getSubscriptionsHandler"; import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { isEnterprise } from "@/lib/enterprise/isEnterprise"; -import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getActiveSubscription } from "@/lib/stripe/getActiveSubscription"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -23,11 +23,12 @@ vi.mock("@/lib/enterprise/isEnterprise", () => ({ isEnterprise: vi.fn(), })); -vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ - getActiveSubscriptionDetails: vi.fn(), +vi.mock("@/lib/stripe/getActiveSubscription", () => ({ + getActiveSubscription: vi.fn(), })); const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const validated = { account_id: accountId }; const makeRequest = () => new NextRequest(`http://localhost/api/accounts/${accountId}/subscription`, { method: "GET" }); @@ -45,7 +46,7 @@ describe("getSubscriptionsHandler", () => { }); it("returns 404 when the account has no emails", async () => { - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockResolvedValue([]); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); @@ -56,7 +57,7 @@ describe("getSubscriptionsHandler", () => { }); it("returns 200 isEnterprise for enterprise accounts without calling Stripe", async () => { - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockResolvedValue([ { email: "someone@recoupable.com" }, ] as never); @@ -67,14 +68,14 @@ describe("getSubscriptionsHandler", () => { expect(res.status).toBe(200); expect(body).toEqual({ status: "success", isEnterprise: true }); - expect(getActiveSubscriptionDetails).not.toHaveBeenCalled(); + expect(getActiveSubscription).not.toHaveBeenCalled(); }); it("returns 404 when no active subscription exists for a non-enterprise account", async () => { - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); vi.mocked(isEnterprise).mockReturnValue(false); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getActiveSubscription).mockResolvedValue(null); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); const body = await res.json(); @@ -84,14 +85,11 @@ describe("getSubscriptionsHandler", () => { }); it("returns 200 with the subscription for a paid account", async () => { - const subscription = { - id: "sub_123", - metadata: { accountId }, - } as never; - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + const subscription = { id: "sub_123", metadata: { accountId } } as never; + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); vi.mocked(isEnterprise).mockReturnValue(false); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(subscription); + vi.mocked(getActiveSubscription).mockResolvedValue(subscription); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); const body = await res.json(); @@ -101,11 +99,11 @@ describe("getSubscriptionsHandler", () => { status: "success", subscription: { id: "sub_123", metadata: { accountId } }, }); - expect(getActiveSubscriptionDetails).toHaveBeenCalledWith(accountId); + expect(getActiveSubscription).toHaveBeenCalledWith(accountId); }); it("returns 500 generic error and never leaks the raw exception message", async () => { - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue({ accountId }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockRejectedValue( new Error("db down: connection refused at 10.0.0.1:5432"), ); diff --git a/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts index d3bebb01e..9722e1655 100644 --- a/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts +++ b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/organizations/canAccessAccount", () => ({ const accountId = "550e8400-e29b-41d4-a716-446655440000"; const requesterId = "660e8400-e29b-41d4-a716-446655440000"; +const authOk = { accountId: requesterId, authToken: "t", orgId: null }; const makeRequest = (id = accountId) => new NextRequest(`http://localhost/api/accounts/${id}/subscription`, { method: "GET", @@ -37,11 +38,13 @@ describe("validateGetSubscriptionRequest", () => { ["not-a-uuid", "non-UUID id"], ["", "empty id"], ])("returns 400 for %s (%s)", async id => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + const result = await validateGetSubscriptionRequest(makeRequest(), id); expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(400); - expect(validateAuthContext).not.toHaveBeenCalled(); + expect(selectAccounts).not.toHaveBeenCalled(); }); it("returns the auth error when authentication fails", async () => { @@ -55,11 +58,7 @@ describe("validateGetSubscriptionRequest", () => { }); it("returns 404 when the account does not exist", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: requesterId, - authToken: "t", - orgId: null, - }); + vi.mocked(validateAuthContext).mockResolvedValue(authOk); vi.mocked(selectAccounts).mockResolvedValue([]); const result = await validateGetSubscriptionRequest(makeRequest(), accountId); @@ -70,11 +69,7 @@ describe("validateGetSubscriptionRequest", () => { }); it("returns 403 when the requester cannot access the account", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: requesterId, - authToken: "t", - orgId: null, - }); + vi.mocked(validateAuthContext).mockResolvedValue(authOk); vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); vi.mocked(canAccessAccount).mockResolvedValue(false); @@ -91,11 +86,7 @@ describe("validateGetSubscriptionRequest", () => { }); it("does not pass the target accountId as an override to validateAuthContext", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: requesterId, - authToken: "t", - orgId: null, - }); + vi.mocked(validateAuthContext).mockResolvedValue(authOk); vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); vi.mocked(canAccessAccount).mockResolvedValue(true); @@ -104,22 +95,17 @@ describe("validateGetSubscriptionRequest", () => { // Regression: passing `{ accountId: id }` here would rewrite // authResult.accountId to the target, collapsing the access check into // a self-check that always returns true. - expect(validateAuthContext).toHaveBeenCalledWith(expect.anything()); const call = vi.mocked(validateAuthContext).mock.calls[0]; expect(call[1]).toBeUndefined(); }); - it("returns the validated accountId on success", async () => { - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: requesterId, - authToken: "t", - orgId: null, - }); + it("returns the validated account_id on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); vi.mocked(canAccessAccount).mockResolvedValue(true); const result = await validateGetSubscriptionRequest(makeRequest(), accountId); - expect(result).toEqual({ accountId }); + expect(result).toEqual({ account_id: accountId }); }); }); diff --git a/lib/subscriptions/getSubscriptionsHandler.ts b/lib/subscriptions/getSubscriptionsHandler.ts index 83f96cb97..7f450fe12 100644 --- a/lib/subscriptions/getSubscriptionsHandler.ts +++ b/lib/subscriptions/getSubscriptionsHandler.ts @@ -1,20 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { isEnterprise } from "@/lib/enterprise/isEnterprise"; -import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getActiveSubscription } from "@/lib/stripe/getActiveSubscription"; /** - * Handler for GET /api/accounts/{id}/subscription. - * - * Response body mirrors the legacy Express `/api/subscriptions?accountId=...` - * endpoint. The `subscription` field is a raw `Stripe.Subscription` — its - * fields stay camelCase by design (third-party typed payload), even though - * the rest of the API uses snake_case. - * - * @returns 200 with `{ status, subscription }` or `{ status, isEnterprise }`, - * or 400/401/403/404/500 on error. + * Handler for GET /api/accounts/{id}/subscription. The `subscription` field is + * a raw `Stripe.Subscription` — its keys stay camelCase by design (third-party + * typed payload), despite the rest of the API being snake_case. */ export async function getSubscriptionsHandler( request: NextRequest, @@ -24,35 +19,22 @@ export async function getSubscriptionsHandler( const { id } = await params; const validated = await validateGetSubscriptionRequest(request, id); - if (validated instanceof NextResponse) { - return validated; - } + if (validated instanceof NextResponse) return validated; - const { accountId } = validated; + const { account_id } = validated; - const accountEmails = await selectAccountEmails({ accountIds: accountId }); - if (!accountEmails || accountEmails.length === 0) { - return NextResponse.json( - { status: "error", error: "Account not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const accountEmails = await selectAccountEmails({ accountIds: account_id }); + if (!accountEmails?.length) return errorResponse("Account not found", 404); - const isAccountEnterprise = accountEmails.some(record => isEnterprise(record.email || "")); - if (isAccountEnterprise) { + if (accountEmails.some(record => isEnterprise(record.email || ""))) { return NextResponse.json( { status: "success", isEnterprise: true }, { status: 200, headers: getCorsHeaders() }, ); } - const subscription = await getActiveSubscriptionDetails(accountId); - if (!subscription) { - return NextResponse.json( - { status: "error", error: "No active subscription found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const subscription = await getActiveSubscription(account_id); + if (!subscription) return errorResponse("No active subscription found", 404); return NextResponse.json( { status: "success", subscription }, @@ -60,9 +42,6 @@ export async function getSubscriptionsHandler( ); } catch (error) { console.error("[ERROR] getSubscriptionsHandler:", error); - return NextResponse.json( - { status: "error", error: "Internal server error" }, - { status: 500, headers: getCorsHeaders() }, - ); + return errorResponse("Internal server error", 500); } } diff --git a/lib/subscriptions/validateGetSubscriptionRequest.ts b/lib/subscriptions/validateGetSubscriptionRequest.ts index 0e594e073..4d9cda915 100644 --- a/lib/subscriptions/validateGetSubscriptionRequest.ts +++ b/lib/subscriptions/validateGetSubscriptionRequest.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; @@ -11,58 +12,32 @@ export const getSubscriptionParamsSchema = z.object({ export type GetSubscriptionParams = z.infer; -export interface ValidatedGetSubscriptionRequest { - accountId: string; -} - /** - * Validates GET /api/accounts/{id}/subscription: 400 bad UUID, 401 unauth, - * 404 missing account, 403 no access. The path id is NOT passed as an auth - * override — doing so would rewrite the caller's id to the target and - * collapse the access check into a self-check that always passes. + * Bundles auth, path-id parsing, account existence (404), and account-access + * check (403). Path id is not passed as an auth override — that would collapse + * the access check into a self-check that always passes. */ export async function validateGetSubscriptionRequest( request: NextRequest, id: string, -): Promise { +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + const parsed = getSubscriptionParamsSchema.safeParse({ account_id: id }); if (!parsed.success) { const firstError = parsed.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { status: 400, headers: getCorsHeaders() }, - ); - } - - const accountId = parsed.data.account_id; - - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; + return validationErrorResponse(firstError.message, firstError.path); } - const existing = await selectAccounts(accountId); - if (!existing.length) { - return NextResponse.json( - { status: "error", error: "Account not found" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const [account] = await selectAccounts(parsed.data.account_id); + if (!account) return errorResponse("Account not found", 404); const hasAccess = await canAccessAccount({ currentAccountId: authResult.accountId, - targetAccountId: accountId, + targetAccountId: parsed.data.account_id, }); - if (!hasAccess) { - return NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403, headers: getCorsHeaders() }, - ); - } + if (!hasAccess) return errorResponse("Unauthorized", 403); - return { accountId }; + return parsed.data; } From 7b44cd1066b8c99de5d942df1702317e0b5b886e Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 04:16:07 +0530 Subject: [PATCH 3/4] fix: drop redundant no-emails-404 branch (account existence already asserted in validator) --- .../__tests__/getSubscriptionsHandler.test.ts | 8 +++++--- lib/subscriptions/getSubscriptionsHandler.ts | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts index 4014269ad..cd56106f4 100644 --- a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts +++ b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts @@ -45,15 +45,17 @@ describe("getSubscriptionsHandler", () => { expect(selectAccountEmails).not.toHaveBeenCalled(); }); - it("returns 404 when the account has no emails", async () => { + it("falls through to Stripe when the account has no emails (not treated as 404)", async () => { vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); vi.mocked(selectAccountEmails).mockResolvedValue([]); + vi.mocked(getActiveSubscription).mockResolvedValue(null); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); - const body = await res.json(); expect(res.status).toBe(404); - expect(body).toEqual({ status: "error", error: "Account not found" }); + expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" }); + expect(getActiveSubscription).toHaveBeenCalledWith(accountId); + expect(isEnterprise).not.toHaveBeenCalled(); }); it("returns 200 isEnterprise for enterprise accounts without calling Stripe", async () => { diff --git a/lib/subscriptions/getSubscriptionsHandler.ts b/lib/subscriptions/getSubscriptionsHandler.ts index 7f450fe12..e621d7631 100644 --- a/lib/subscriptions/getSubscriptionsHandler.ts +++ b/lib/subscriptions/getSubscriptionsHandler.ts @@ -24,8 +24,6 @@ export async function getSubscriptionsHandler( const { account_id } = validated; const accountEmails = await selectAccountEmails({ accountIds: account_id }); - if (!accountEmails?.length) return errorResponse("Account not found", 404); - if (accountEmails.some(record => isEnterprise(record.email || ""))) { return NextResponse.json( { status: "success", isEnterprise: true }, From 3f922d498ef89a87e5d489ba03ab827575f0afe1 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 04:19:28 +0530 Subject: [PATCH 4/4] refactor: move subscription resolution into getAccountSubscription helper Discriminated return (enterprise | subscription | null) collapses orchestration out of the handler. Owns emails lookup + enterprise check + Stripe search. Tests kept at handler + validator level only. --- .../__tests__/getActiveSubscription.test.ts | 57 -------------- lib/stripe/getActiveSubscription.ts | 24 ------ .../__tests__/getSubscriptionsHandler.test.ts | 78 ++++++------------- lib/subscriptions/getAccountSubscription.ts | 37 +++++++++ lib/subscriptions/getSubscriptionsHandler.ts | 27 ++----- 5 files changed, 67 insertions(+), 156 deletions(-) delete mode 100644 lib/stripe/__tests__/getActiveSubscription.test.ts delete mode 100644 lib/stripe/getActiveSubscription.ts create mode 100644 lib/subscriptions/getAccountSubscription.ts diff --git a/lib/stripe/__tests__/getActiveSubscription.test.ts b/lib/stripe/__tests__/getActiveSubscription.test.ts deleted file mode 100644 index cd15caba6..000000000 --- a/lib/stripe/__tests__/getActiveSubscription.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -import stripeClient from "@/lib/stripe/client"; -import { getActiveSubscription } from "../getActiveSubscription"; - -vi.mock("@/lib/stripe/client", () => ({ - default: { subscriptions: { search: vi.fn() } }, -})); - -const searchMock = vi.mocked(stripeClient.subscriptions.search); - -const accountId = "550e8400-e29b-41d4-a716-446655440000"; - -describe("getActiveSubscription", () => { - beforeEach(() => { - searchMock.mockReset(); - }); - - it("queries Stripe with metadata + active status filter", async () => { - searchMock.mockResolvedValue({ data: [] }); - - await getActiveSubscription(accountId); - - expect(searchMock).toHaveBeenCalledWith({ - query: `status:"active" AND metadata["accountId"]:"${accountId}"`, - limit: 1, - }); - }); - - it("returns the first matching subscription", async () => { - const sub = { id: "sub_1", metadata: { accountId } }; - searchMock.mockResolvedValue({ data: [sub] }); - - const result = await getActiveSubscription(accountId); - - expect(result).toBe(sub); - }); - - it("returns null when no subscription matches", async () => { - searchMock.mockResolvedValue({ data: [] }); - - const result = await getActiveSubscription(accountId); - - expect(result).toBeNull(); - }); - - it("returns null and logs on Stripe error", async () => { - const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - searchMock.mockRejectedValue(new Error("stripe down")); - - const result = await getActiveSubscription(accountId); - - expect(result).toBeNull(); - expect(errSpy).toHaveBeenCalled(); - errSpy.mockRestore(); - }); -}); diff --git a/lib/stripe/getActiveSubscription.ts b/lib/stripe/getActiveSubscription.ts deleted file mode 100644 index d6c3851be..000000000 --- a/lib/stripe/getActiveSubscription.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Stripe from "stripe"; -import stripeClient from "@/lib/stripe/client"; - -/** - * Returns the first active Stripe subscription tagged with `metadata.accountId`, - * or null. Uses `subscriptions.search` so the metadata + status filter runs on - * Stripe's side — scales independently of total subscription count (the prior - * `list({ limit: 100 })` + client-side filter was lossy once the active set - * crossed 100). Search is eventually consistent (~1 min lag after writes). - */ -export async function getActiveSubscription( - accountId: string, -): Promise { - try { - const result = await stripeClient.subscriptions.search({ - query: `status:"active" AND metadata["accountId"]:"${accountId}"`, - limit: 1, - }); - return result.data[0] ?? null; - } catch (error) { - console.error("[ERROR] getActiveSubscription:", error); - return null; - } -} diff --git a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts index cd56106f4..2c0f8a801 100644 --- a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts +++ b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts @@ -3,9 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSubscriptionsHandler } from "../getSubscriptionsHandler"; import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { isEnterprise } from "@/lib/enterprise/isEnterprise"; -import { getActiveSubscription } from "@/lib/stripe/getActiveSubscription"; +import { getAccountSubscription } from "../getAccountSubscription"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -15,16 +13,8 @@ vi.mock("../validateGetSubscriptionRequest", () => ({ validateGetSubscriptionRequest: vi.fn(), })); -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: vi.fn(), -})); - -vi.mock("@/lib/enterprise/isEnterprise", () => ({ - isEnterprise: vi.fn(), -})); - -vi.mock("@/lib/stripe/getActiveSubscription", () => ({ - getActiveSubscription: vi.fn(), +vi.mock("../getAccountSubscription", () => ({ + getAccountSubscription: vi.fn(), })); const accountId = "550e8400-e29b-41d4-a716-446655440000"; @@ -42,73 +32,49 @@ describe("getSubscriptionsHandler", () => { const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); expect(res).toBe(err); - expect(selectAccountEmails).not.toHaveBeenCalled(); + expect(getAccountSubscription).not.toHaveBeenCalled(); }); - it("falls through to Stripe when the account has no emails (not treated as 404)", async () => { + it("returns 200 isEnterprise for enterprise accounts", async () => { vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); - vi.mocked(selectAccountEmails).mockResolvedValue([]); - vi.mocked(getActiveSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscription).mockResolvedValue({ isEnterprise: true }); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" }); - expect(getActiveSubscription).toHaveBeenCalledWith(accountId); - expect(isEnterprise).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success", isEnterprise: true }); }); - it("returns 200 isEnterprise for enterprise accounts without calling Stripe", async () => { + it("returns 200 with the subscription for a paid account", async () => { + const subscription = { id: "sub_123", metadata: { accountId } } as never; vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); - vi.mocked(selectAccountEmails).mockResolvedValue([ - { email: "someone@recoupable.com" }, - ] as never); - vi.mocked(isEnterprise).mockReturnValue(true); + vi.mocked(getAccountSubscription).mockResolvedValue({ subscription }); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); - const body = await res.json(); expect(res.status).toBe(200); - expect(body).toEqual({ status: "success", isEnterprise: true }); - expect(getActiveSubscription).not.toHaveBeenCalled(); + expect(await res.json()).toEqual({ + status: "success", + subscription: { id: "sub_123", metadata: { accountId } }, + }); + expect(getAccountSubscription).toHaveBeenCalledWith(accountId); }); - it("returns 404 when no active subscription exists for a non-enterprise account", async () => { + it("returns 404 when no active subscription exists", async () => { vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); - vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); - vi.mocked(isEnterprise).mockReturnValue(false); - vi.mocked(getActiveSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscription).mockResolvedValue(null); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); - const body = await res.json(); expect(res.status).toBe(404); - expect(body).toEqual({ status: "error", error: "No active subscription found" }); - }); - - it("returns 200 with the subscription for a paid account", async () => { - const subscription = { id: "sub_123", metadata: { accountId } } as never; - vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); - vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "user@example.com" }] as never); - vi.mocked(isEnterprise).mockReturnValue(false); - vi.mocked(getActiveSubscription).mockResolvedValue(subscription); - - const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body).toEqual({ - status: "success", - subscription: { id: "sub_123", metadata: { accountId } }, - }); - expect(getActiveSubscription).toHaveBeenCalledWith(accountId); + expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" }); }); it("returns 500 generic error and never leaks the raw exception message", async () => { vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); - vi.mocked(selectAccountEmails).mockRejectedValue( - new Error("db down: connection refused at 10.0.0.1:5432"), - ); + vi.mocked(getAccountSubscription).mockImplementation(() => { + throw new Error("db down: connection refused at 10.0.0.1:5432"); + }); const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); const body = await res.json(); diff --git a/lib/subscriptions/getAccountSubscription.ts b/lib/subscriptions/getAccountSubscription.ts new file mode 100644 index 000000000..489ded3f4 --- /dev/null +++ b/lib/subscriptions/getAccountSubscription.ts @@ -0,0 +1,37 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { isEnterprise } from "@/lib/enterprise/isEnterprise"; + +export type AccountSubscription = + | { isEnterprise: true } + | { subscription: Stripe.Subscription } + | null; + +/** + * Resolves an account's subscription state: enterprise (via email domain), + * an active Stripe subscription (`metadata.accountId` match), or null. + * + * Uses `stripe.subscriptions.search` so the metadata + status filter runs on + * Stripe's side — scales independently of total subscription count (a prior + * `list({ limit: 100 })` + client-side filter was lossy past 100). Search is + * eventually consistent (~1 min lag after writes). + */ +export async function getAccountSubscription(accountId: string): Promise { + try { + const emails = await selectAccountEmails({ accountIds: accountId }); + if (emails.some(record => isEnterprise(record.email || ""))) { + return { isEnterprise: true }; + } + + const result = await stripeClient.subscriptions.search({ + query: `status:"active" AND metadata["accountId"]:"${accountId}"`, + limit: 1, + }); + const subscription = result.data[0]; + return subscription ? { subscription } : null; + } catch (error) { + console.error("[ERROR] getAccountSubscription:", error); + return null; + } +} diff --git a/lib/subscriptions/getSubscriptionsHandler.ts b/lib/subscriptions/getSubscriptionsHandler.ts index e621d7631..1dcde69c5 100644 --- a/lib/subscriptions/getSubscriptionsHandler.ts +++ b/lib/subscriptions/getSubscriptionsHandler.ts @@ -2,14 +2,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { errorResponse } from "@/lib/networking/errorResponse"; import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; -import { isEnterprise } from "@/lib/enterprise/isEnterprise"; -import { getActiveSubscription } from "@/lib/stripe/getActiveSubscription"; +import { getAccountSubscription } from "@/lib/subscriptions/getAccountSubscription"; /** - * Handler for GET /api/accounts/{id}/subscription. The `subscription` field is - * a raw `Stripe.Subscription` — its keys stay camelCase by design (third-party - * typed payload), despite the rest of the API being snake_case. + * Handler for GET /api/accounts/{id}/subscription. When the account is a paid + * Stripe customer, `subscription` is a raw `Stripe.Subscription` — its keys + * stay camelCase by design (third-party typed payload), despite the rest of + * the API being snake_case. */ export async function getSubscriptionsHandler( request: NextRequest, @@ -21,21 +20,11 @@ export async function getSubscriptionsHandler( const validated = await validateGetSubscriptionRequest(request, id); if (validated instanceof NextResponse) return validated; - const { account_id } = validated; - - const accountEmails = await selectAccountEmails({ accountIds: account_id }); - if (accountEmails.some(record => isEnterprise(record.email || ""))) { - return NextResponse.json( - { status: "success", isEnterprise: true }, - { status: 200, headers: getCorsHeaders() }, - ); - } - - const subscription = await getActiveSubscription(account_id); - if (!subscription) return errorResponse("No active subscription found", 404); + const result = await getAccountSubscription(validated.account_id); + if (!result) return errorResponse("No active subscription found", 404); return NextResponse.json( - { status: "success", subscription }, + { status: "success", ...result }, { status: 200, headers: getCorsHeaders() }, ); } catch (error) {