From 754ccbbecfa62815b5cab4789b80ddf4497f27de Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 03:45:58 +0530 Subject: [PATCH 01/13] feat(api): migrate GET /api/artists/pro Admin-scoped flat route returning the deduped list of artist IDs owned by "pro" accounts (enterprise email domain or active Stripe subscription). Ports Stripe client + getActiveSubscriptions inline until the subscription PR lands; reuses validateAdminAuth for scope. --- app/api/artists/pro/route.ts | 29 +++++++ .../__tests__/getArtistsProHandler.test.ts | 80 +++++++++++++++++++ .../validateGetArtistsProRequest.test.ts | 51 ++++++++++++ lib/artists/getArtistsProHandler.ts | 27 +++++++ lib/artists/validateGetArtistsProRequest.ts | 18 +++++ lib/enterprise/consts.ts | 12 +++ lib/enterprise/getAllEnterpriseAccounts.ts | 14 ++++ lib/enterprise/getEnterpriseArtists.ts | 31 +++++++ lib/enterprise/getSubscriberAccountEmails.ts | 18 +++++ lib/stripe/client.ts | 25 ++++++ lib/stripe/getActiveSubscriptions.ts | 29 +++++++ .../selectAccountArtistIds.ts | 21 +++++ .../selectAccountEmailsByAccountIds.ts | 22 +++++ .../selectAccountEmailsByDomain.ts | 21 +++++ package.json | 1 + pnpm-lock.yaml | 12 +++ 16 files changed, 411 insertions(+) create mode 100644 app/api/artists/pro/route.ts create mode 100644 lib/artists/__tests__/getArtistsProHandler.test.ts create mode 100644 lib/artists/__tests__/validateGetArtistsProRequest.test.ts create mode 100644 lib/artists/getArtistsProHandler.ts create mode 100644 lib/artists/validateGetArtistsProRequest.ts create mode 100644 lib/enterprise/consts.ts create mode 100644 lib/enterprise/getAllEnterpriseAccounts.ts create mode 100644 lib/enterprise/getEnterpriseArtists.ts create mode 100644 lib/enterprise/getSubscriberAccountEmails.ts create mode 100644 lib/stripe/client.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/supabase/account_artist_ids/selectAccountArtistIds.ts create mode 100644 lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts create mode 100644 lib/supabase/account_emails/selectAccountEmailsByDomain.ts diff --git a/app/api/artists/pro/route.ts b/app/api/artists/pro/route.ts new file mode 100644 index 000000000..a6036685c --- /dev/null +++ b/app/api/artists/pro/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getArtistsProHandler } from "@/lib/artists/getArtistsProHandler"; + +/** + * 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/artists/pro + * + * Returns a deduplicated list of artist IDs owned by "pro" accounts + * (enterprise-domain emails or active Stripe subscriptions). Admin-scoped — + * the response is the paying-customer list. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ status, artists, [error] }`. + */ +export async function GET(request: NextRequest) { + return getArtistsProHandler(request); +} diff --git a/lib/artists/__tests__/getArtistsProHandler.test.ts b/lib/artists/__tests__/getArtistsProHandler.test.ts new file mode 100644 index 000000000..77b3d4377 --- /dev/null +++ b/lib/artists/__tests__/getArtistsProHandler.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getArtistsProHandler } from "../getArtistsProHandler"; +import { validateGetArtistsProRequest } from "../validateGetArtistsProRequest"; +import { getEnterpriseArtists } from "@/lib/enterprise/getEnterpriseArtists"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetArtistsProRequest", () => ({ + validateGetArtistsProRequest: vi.fn(), +})); + +vi.mock("@/lib/enterprise/getEnterpriseArtists", () => ({ + getEnterpriseArtists: vi.fn(), +})); + +describe("getArtistsProHandler", () => { + const request = new NextRequest("http://localhost/api/artists/pro"); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue({}); + }); + + it.each([ + ["enterprise-only", ["a1", "a2"]], + ["subscription-only", ["b1"]], + ["both enterprise and subscription, deduped", ["a1", "b1", "c1"]], + ["neither — empty", []], + ])("returns %s artists on 200", async (_label, artists) => { + vi.mocked(getEnterpriseArtists).mockResolvedValue(artists); + + const response = await getArtistsProHandler(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ status: "success", artists }); + }); + + it("propagates the 401 response when validator fails auth", async () => { + const err = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue(err); + + const response = await getArtistsProHandler(request); + + expect(response).toBe(err); + expect(getEnterpriseArtists).not.toHaveBeenCalled(); + }); + + it("propagates the 403 response for non-admin callers", async () => { + const err = NextResponse.json({ status: "error", message: "Forbidden" }, { status: 403 }); + vi.mocked(validateGetArtistsProRequest).mockResolvedValue(err); + + const response = await getArtistsProHandler(request); + + expect(response).toBe(err); + expect(getEnterpriseArtists).not.toHaveBeenCalled(); + }); + + it("returns a generic 500 without leaking error details", async () => { + vi.mocked(getEnterpriseArtists).mockRejectedValue( + new Error("db.internal.host=10.0.0.5 connection refused"), + ); + + const response = await getArtistsProHandler(request); + const bodyText = JSON.stringify(await response.json()); + + expect(response.status).toBe(500); + expect(bodyText).not.toContain("db.internal"); + expect(bodyText).not.toContain("10.0.0.5"); + expect(bodyText).not.toContain("connection refused"); + expect(JSON.parse(bodyText)).toEqual({ + status: "error", + artists: [], + error: "Internal server error", + }); + }); +}); diff --git a/lib/artists/__tests__/validateGetArtistsProRequest.test.ts b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts new file mode 100644 index 000000000..066ebc290 --- /dev/null +++ b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetArtistsProRequest } from "../validateGetArtistsProRequest"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/admins/validateAdminAuth", () => ({ + validateAdminAuth: vi.fn(), +})); + +describe("validateGetArtistsProRequest", () => { + const request = new NextRequest("http://localhost/api/artists/pro"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns an empty payload when admin auth passes", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue({ + accountId: "acc-1", + orgId: null, + authToken: "t", + }); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toEqual({}); + expect(validateAdminAuth).toHaveBeenCalledWith(request); + }); + + it("propagates the 401 NextResponse when auth is missing", async () => { + const err = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateAdminAuth).mockResolvedValue(err); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toBe(err); + }); + + it("propagates the 403 NextResponse when caller is not admin", async () => { + const err = NextResponse.json({ status: "error", message: "Forbidden" }, { status: 403 }); + vi.mocked(validateAdminAuth).mockResolvedValue(err); + + const result = await validateGetArtistsProRequest(request); + + expect(result).toBe(err); + }); +}); diff --git a/lib/artists/getArtistsProHandler.ts b/lib/artists/getArtistsProHandler.ts new file mode 100644 index 000000000..a8e73ee22 --- /dev/null +++ b/lib/artists/getArtistsProHandler.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getEnterpriseArtists } from "@/lib/enterprise/getEnterpriseArtists"; +import { validateGetArtistsProRequest } from "@/lib/artists/validateGetArtistsProRequest"; + +export async function getArtistsProHandler(request: NextRequest): Promise { + try { + const validated = await validateGetArtistsProRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const artists = await getEnterpriseArtists(); + + return NextResponse.json( + { status: "success", artists }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + // Never leak error.message — it can surface DB hosts / stack hints. + console.error("[ERROR] getArtistsProHandler:", error); + return NextResponse.json( + { status: "error", artists: [], error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/artists/validateGetArtistsProRequest.ts b/lib/artists/validateGetArtistsProRequest.ts new file mode 100644 index 000000000..25b4ffe28 --- /dev/null +++ b/lib/artists/validateGetArtistsProRequest.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +/** + * Scope, not access: the response is the list of all pro-tier artists — the + * paying-customer set. There is no per-row id to bind an access check to, so + * admin-scope is enforced at the request boundary. Reuses `validateAdminAuth` + * (validateAuthContext + Recoup-org membership check) rather than forking. + */ +export async function validateGetArtistsProRequest( + request: NextRequest, +): Promise> { + const auth = await validateAdminAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + return {}; +} diff --git a/lib/enterprise/consts.ts b/lib/enterprise/consts.ts new file mode 100644 index 000000000..42309cffd --- /dev/null +++ b/lib/enterprise/consts.ts @@ -0,0 +1,12 @@ +/** + * Email domains that unconditionally mark an account as "pro" regardless of + * Stripe subscription state. Ported from Recoup-Agent-APIs/lib/consts.ts. + */ +export const ENTERPRISE_DOMAINS: ReadonlySet = new Set([ + "recoupable.com", + "rostrum.com", + "spaceheatermusic.io", + "fatbeats.com", + "cantorarecords.net", + "rostrumrecords.com", +]); diff --git a/lib/enterprise/getAllEnterpriseAccounts.ts b/lib/enterprise/getAllEnterpriseAccounts.ts new file mode 100644 index 000000000..b009bb035 --- /dev/null +++ b/lib/enterprise/getAllEnterpriseAccounts.ts @@ -0,0 +1,14 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; +import { selectAccountEmailsByDomain } from "@/lib/supabase/account_emails/selectAccountEmailsByDomain"; + +/** + * Fan-out an ilike query per enterprise domain in parallel and flatten the + * results. Not deduped here — downstream `getEnterpriseArtists` merges with + * the subscriber set and dedupes on `account_id`. + */ +export async function getAllEnterpriseAccounts() { + const emailArrays = await Promise.all( + Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmailsByDomain(domain)), + ); + return emailArrays.flat(); +} diff --git a/lib/enterprise/getEnterpriseArtists.ts b/lib/enterprise/getEnterpriseArtists.ts new file mode 100644 index 000000000..9d726fc47 --- /dev/null +++ b/lib/enterprise/getEnterpriseArtists.ts @@ -0,0 +1,31 @@ +import { getAllEnterpriseAccounts } from "@/lib/enterprise/getAllEnterpriseAccounts"; +import { getSubscriberAccountEmails } from "@/lib/enterprise/getSubscriberAccountEmails"; +import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; + +/** + * Resolve the deduped list of artist_ids owned by "pro" accounts — enterprise + * email domain or active Stripe subscription. Dedupes at both layers + * (account_id then artist_id) so accounts present in both sources and artists + * shared across accounts do not produce cartesian-product rows. Short-circuits + * when no pro accounts are found before hitting `account_artist_ids`. + */ +export async function getEnterpriseArtists(): Promise { + const [enterpriseEmails, subscriberEmails] = await Promise.all([ + getAllEnterpriseAccounts(), + getSubscriberAccountEmails(), + ]); + + const accountIds = new Set( + [...enterpriseEmails, ...subscriberEmails] + .map(row => row.account_id) + .filter((id): id is string => Boolean(id)), + ); + + if (accountIds.size === 0) return []; + + const artistIdRows = await selectAccountArtistIds(Array.from(accountIds)); + + return Array.from( + new Set(artistIdRows.map(row => row.artist_id).filter((id): id is string => Boolean(id))), + ); +} diff --git a/lib/enterprise/getSubscriberAccountEmails.ts b/lib/enterprise/getSubscriberAccountEmails.ts new file mode 100644 index 000000000..5d78be92f --- /dev/null +++ b/lib/enterprise/getSubscriberAccountEmails.ts @@ -0,0 +1,18 @@ +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import { selectAccountEmailsByAccountIds } from "@/lib/supabase/account_emails/selectAccountEmailsByAccountIds"; + +/** + * Resolve subscriber account_ids from Stripe subscription metadata and look + * up their account_emails. The Stripe call is capped at 100 per + * `getActiveSubscriptions` — acceptable today, revisit with pagination if the + * active set ever exceeds that. + */ +export async function getSubscriberAccountEmails() { + const activeSubscriptions = await getActiveSubscriptions(); + + const accountIds = activeSubscriptions + .map(subscription => subscription.metadata?.accountId) + .filter((accountId): accountId is string => Boolean(accountId)); + + return selectAccountEmailsByAccountIds(accountIds); +} 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/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..78ce34d44 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,29 @@ +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. + */ +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/supabase/account_artist_ids/selectAccountArtistIds.ts b/lib/supabase/account_artist_ids/selectAccountArtistIds.ts new file mode 100644 index 000000000..bb1d97f11 --- /dev/null +++ b/lib/supabase/account_artist_ids/selectAccountArtistIds.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Project `artist_id` values for the given account_ids. Returns `[]` when + * `account_ids` is empty to avoid the undefined `.in(..., [])` behavior. + */ +export async function selectAccountArtistIds(accountIds: string[]) { + if (accountIds.length === 0) return []; + + const { data, error } = await supabase + .from("account_artist_ids") + .select("artist_id") + .in("account_id", accountIds); + + if (error) { + console.error("Error fetching account_artist_ids:", error); + return []; + } + + return data ?? []; +} diff --git a/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts b/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts new file mode 100644 index 000000000..b3fd98278 --- /dev/null +++ b/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts @@ -0,0 +1,22 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Select account_emails rows for the given set of account_ids. Short-circuits + * to an empty array when `account_ids` is empty; Supabase's `.in(..., [])` + * behavior is undefined and returns 400 on some paths. + */ +export async function selectAccountEmailsByAccountIds(accountIds: string[]) { + if (accountIds.length === 0) return []; + + const { data, error } = await supabase + .from("account_emails") + .select("*") + .in("account_id", accountIds); + + if (error) { + console.error("Error fetching account_emails by account_ids:", error); + return []; + } + + return data ?? []; +} diff --git a/lib/supabase/account_emails/selectAccountEmailsByDomain.ts b/lib/supabase/account_emails/selectAccountEmailsByDomain.ts new file mode 100644 index 000000000..30276471e --- /dev/null +++ b/lib/supabase/account_emails/selectAccountEmailsByDomain.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Select account_emails whose address contains `@` anywhere in the + * local-or-host part — matches the legacy `ilike("email", "%@%")` + * quirk (it will match `user+tag@.anything`), preserved intentionally + * for wire parity with the Express service. + */ +export async function selectAccountEmailsByDomain(domain: string) { + const { data, error } = await supabase + .from("account_emails") + .select("*") + .ilike("email", `%@${domain}%`); + + if (error) { + console.error("Error fetching account_emails by domain:", error); + return []; + } + + return data ?? []; +} diff --git a/package.json b/package.json index 5d12b8b07..173d6d416 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.7.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..97404e69c 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.7.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 33b2c3ad1715714f1a8f879ab07e15f940b23a07 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 04:00:57 +0530 Subject: [PATCH 02/13] fix(stripe): defer client init to first call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next build on Vercel evaluates lib/stripe/client.ts while collecting page data; throwing at module-load when STRIPE_SECRET_KEY is absent from the preview env killed the build. Move the guard into a lazy factory so build-time import is always safe — the first request still surfaces a 500 via the handler's catch when the secret is genuinely missing. Co-Authored-By: Claude Opus 4.7 --- lib/stripe/client.ts | 41 +++++++++++++++++++--------- lib/stripe/getActiveSubscriptions.ts | 2 +- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts index 80dcf0c95..483399ae1 100644 --- a/lib/stripe/client.ts +++ b/lib/stripe/client.ts @@ -7,19 +7,34 @@ import Stripe from "stripe"; */ 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.", - ); -} +/** + * Lazy Stripe client. Do not throw at module-load: Next.js evaluates this + * module during `next build` (on Vercel, with `NODE_ENV=production`) while + * collecting page data, and the preview env may not have the secret wired + * up yet. Throwing here would fail the build before any request runs. + * + * Instead, defer the missing-secret check to the first call; the handler + * catches and maps to a generic 500, which is the right shape for a + * misconfigured environment. + */ +let cachedClient: Stripe | null = null; + +function stripeClient(): Stripe { + if (cachedClient) return cachedClient; -const stripeClient = new Stripe(stripeSecretKey ?? "sk_test_unset", { - apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, -}); + const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK; + + if (!stripeSecretKey) { + throw new Error( + "Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.", + ); + } + + cachedClient = new Stripe(stripeSecretKey, { + apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, + }); + + return cachedClient; +} export default stripeClient; diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 78ce34d44..7f9fa8608 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -13,7 +13,7 @@ import stripeClient from "@/lib/stripe/client"; export async function getActiveSubscriptions(accountId?: string): Promise { try { const nowSec = Math.floor(Date.now() / 1000); - const subscriptions = await stripeClient.subscriptions.list({ + const subscriptions = await stripeClient().subscriptions.list({ limit: 100, current_period_end: { gt: nowSec }, }); From 2ead2d39fc037f3b373a7fff070a003d4e6e2d85 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 04:54:42 +0530 Subject: [PATCH 03/13] refactor: fold account_emails helpers into generic selectAccountEmails Add optional domain filter (ilike %@%) to the existing selectAccountEmails helper and drop the per-filter wrappers. One helper, three optional filters (emails, accountIds, domain). --- lib/enterprise/getAllEnterpriseAccounts.ts | 4 ++-- lib/enterprise/getSubscriberAccountEmails.ts | 6 +++-- .../account_emails/selectAccountEmails.ts | 21 +++++++++--------- .../selectAccountEmailsByAccountIds.ts | 22 ------------------- .../selectAccountEmailsByDomain.ts | 21 ------------------ 5 files changed, 17 insertions(+), 57 deletions(-) delete mode 100644 lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts delete mode 100644 lib/supabase/account_emails/selectAccountEmailsByDomain.ts diff --git a/lib/enterprise/getAllEnterpriseAccounts.ts b/lib/enterprise/getAllEnterpriseAccounts.ts index b009bb035..f8a2aac57 100644 --- a/lib/enterprise/getAllEnterpriseAccounts.ts +++ b/lib/enterprise/getAllEnterpriseAccounts.ts @@ -1,5 +1,5 @@ import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; -import { selectAccountEmailsByDomain } from "@/lib/supabase/account_emails/selectAccountEmailsByDomain"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; /** * Fan-out an ilike query per enterprise domain in parallel and flatten the @@ -8,7 +8,7 @@ import { selectAccountEmailsByDomain } from "@/lib/supabase/account_emails/selec */ export async function getAllEnterpriseAccounts() { const emailArrays = await Promise.all( - Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmailsByDomain(domain)), + Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmails({ domain })), ); return emailArrays.flat(); } diff --git a/lib/enterprise/getSubscriberAccountEmails.ts b/lib/enterprise/getSubscriberAccountEmails.ts index 5d78be92f..c0d52360a 100644 --- a/lib/enterprise/getSubscriberAccountEmails.ts +++ b/lib/enterprise/getSubscriberAccountEmails.ts @@ -1,5 +1,5 @@ import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; -import { selectAccountEmailsByAccountIds } from "@/lib/supabase/account_emails/selectAccountEmailsByAccountIds"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; /** * Resolve subscriber account_ids from Stripe subscription metadata and look @@ -14,5 +14,7 @@ export async function getSubscriberAccountEmails() { .map(subscription => subscription.metadata?.accountId) .filter((accountId): accountId is string => Boolean(accountId)); - return selectAccountEmailsByAccountIds(accountIds); + if (accountIds.length === 0) return []; + + return selectAccountEmails({ accountIds }); } diff --git a/lib/supabase/account_emails/selectAccountEmails.ts b/lib/supabase/account_emails/selectAccountEmails.ts index 81f055302..832c800ac 100644 --- a/lib/supabase/account_emails/selectAccountEmails.ts +++ b/lib/supabase/account_emails/selectAccountEmails.ts @@ -2,33 +2,30 @@ import supabase from "@/lib/supabase/serverClient"; import type { Tables } from "@/types/database.types"; /** - * Select account_emails by email addresses and/or account IDs - * - * @param params - The parameters for the query - * @param params.emails - Optional array of email addresses to query - * @param params.accountIds - Optional array of account IDs to query - * @returns Array of account_emails rows + * Select account_emails by email addresses, account IDs, and/or a domain + * substring (ilike `%@%` — matches the legacy behavior that also hits + * `user+tag@.anything`). */ export default async function selectAccountEmails({ emails, accountIds, + domain, }: { emails?: string[]; accountIds?: string | string[]; + domain?: string; }): Promise[]> { let query = supabase.from("account_emails").select("*"); - // Build query based on provided parameters const ids = accountIds ? (Array.isArray(accountIds) ? accountIds : [accountIds]) : []; const hasEmails = Array.isArray(emails) && emails.length > 0; const hasAccountIds = ids.length > 0; + const hasDomain = typeof domain === "string" && domain.length > 0; - // If neither parameter is provided, return empty array - if (!hasEmails && !hasAccountIds) { + if (!hasEmails && !hasAccountIds && !hasDomain) { return []; } - // Apply filters if (hasEmails) { query = query.in("email", emails); } @@ -37,6 +34,10 @@ export default async function selectAccountEmails({ query = query.in("account_id", ids); } + if (hasDomain) { + query = query.ilike("email", `%@${domain}%`); + } + const { data, error } = await query; if (error) { diff --git a/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts b/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts deleted file mode 100644 index b3fd98278..000000000 --- a/lib/supabase/account_emails/selectAccountEmailsByAccountIds.ts +++ /dev/null @@ -1,22 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * Select account_emails rows for the given set of account_ids. Short-circuits - * to an empty array when `account_ids` is empty; Supabase's `.in(..., [])` - * behavior is undefined and returns 400 on some paths. - */ -export async function selectAccountEmailsByAccountIds(accountIds: string[]) { - if (accountIds.length === 0) return []; - - const { data, error } = await supabase - .from("account_emails") - .select("*") - .in("account_id", accountIds); - - if (error) { - console.error("Error fetching account_emails by account_ids:", error); - return []; - } - - return data ?? []; -} diff --git a/lib/supabase/account_emails/selectAccountEmailsByDomain.ts b/lib/supabase/account_emails/selectAccountEmailsByDomain.ts deleted file mode 100644 index 30276471e..000000000 --- a/lib/supabase/account_emails/selectAccountEmailsByDomain.ts +++ /dev/null @@ -1,21 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * Select account_emails whose address contains `@` anywhere in the - * local-or-host part — matches the legacy `ilike("email", "%@%")` - * quirk (it will match `user+tag@.anything`), preserved intentionally - * for wire parity with the Express service. - */ -export async function selectAccountEmailsByDomain(domain: string) { - const { data, error } = await supabase - .from("account_emails") - .select("*") - .ilike("email", `%@${domain}%`); - - if (error) { - console.error("Error fetching account_emails by domain:", error); - return []; - } - - return data ?? []; -} From a65d688329c0aa81c492a31ee8702b07045e6bd4 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 05:00:52 +0530 Subject: [PATCH 04/13] refactor(stripe): paginate getActiveSubscriptions, drop unused accountId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use Stripe SDK's async iterator so we're not capped at 100 rows — the cursor paginates the full active set. The accountId filter param was dead (no caller passed it); drop rather than carry; reintroduce via Stripe Search API if a single-account lookup is ever needed. --- lib/stripe/getActiveSubscriptions.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index 7f9fa8608..c70a117e5 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -2,26 +2,24 @@ 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. + * Fetch all active Stripe subscriptions via cursor pagination (Stripe's async + * iterator). Filters on `current_period_end > now` to preserve the legacy + * semantics (active includes in-grace-period). Returns [] on API error rather + * than throwing — callers treat empty as "no subscribers" and degrade safely. */ -export async function getActiveSubscriptions(accountId?: string): Promise { +export async function getActiveSubscriptions(): Promise { try { const nowSec = Math.floor(Date.now() / 1000); - const subscriptions = await stripeClient().subscriptions.list({ + const subscriptions: Stripe.Subscription[] = []; + + for await (const sub of stripeClient().subscriptions.list({ limit: 100, current_period_end: { gt: nowSec }, - }); - - const data = subscriptions?.data ?? []; - if (!accountId) return data; + })) { + subscriptions.push(sub); + } - return data.filter(sub => sub.metadata?.accountId === accountId); + return subscriptions; } catch (error) { console.error("[ERROR] getActiveSubscriptions:", error); return []; From 0503921ac650207c31de3308c5c840e17996edf6 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:28:19 +0530 Subject: [PATCH 05/13] refactor(stripe): use autoPagingToArray in getActiveSubscriptions --- lib/stripe/getActiveSubscriptions.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts index c70a117e5..c728b435e 100644 --- a/lib/stripe/getActiveSubscriptions.ts +++ b/lib/stripe/getActiveSubscriptions.ts @@ -10,16 +10,9 @@ import stripeClient from "@/lib/stripe/client"; export async function getActiveSubscriptions(): Promise { try { const nowSec = Math.floor(Date.now() / 1000); - const subscriptions: Stripe.Subscription[] = []; - - for await (const sub of stripeClient().subscriptions.list({ - limit: 100, - current_period_end: { gt: nowSec }, - })) { - subscriptions.push(sub); - } - - return subscriptions; + return await stripeClient() + .subscriptions.list({ limit: 100, current_period_end: { gt: nowSec } }) + .autoPagingToArray({ limit: 10000 }); } catch (error) { console.error("[ERROR] getActiveSubscriptions:", error); return []; From 7336ef92269dc331ee9b7b7d25ef56d53a04fff1 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:31:36 +0530 Subject: [PATCH 06/13] refactor(stripe): stream active subscriptions, use native status filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap getActiveSubscriptions for an iterateActiveSubscriptions async generator. Two wins: 1. status: 'active' is Stripe's native filter — no current_period_end vs server-clock comparison (timezone / skew hazards). 2. Yielded incrementally so callers fold results into a Set as they arrive; no hard in-memory cap on the active subscription count. Caller now holds only the deduped accountId strings, not full Subscription objects. --- lib/enterprise/getSubscriberAccountEmails.ts | 24 +++++++++---------- lib/stripe/getActiveSubscriptions.ts | 20 ---------------- lib/stripe/iterateActiveSubscriptions.ts | 25 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 32 deletions(-) delete mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/iterateActiveSubscriptions.ts diff --git a/lib/enterprise/getSubscriberAccountEmails.ts b/lib/enterprise/getSubscriberAccountEmails.ts index c0d52360a..5539074e2 100644 --- a/lib/enterprise/getSubscriberAccountEmails.ts +++ b/lib/enterprise/getSubscriberAccountEmails.ts @@ -1,20 +1,20 @@ -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import { iterateActiveSubscriptions } from "@/lib/stripe/iterateActiveSubscriptions"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; /** - * Resolve subscriber account_ids from Stripe subscription metadata and look - * up their account_emails. The Stripe call is capped at 100 per - * `getActiveSubscriptions` — acceptable today, revisit with pagination if the - * active set ever exceeds that. + * Stream active Stripe subscriptions and collect the `metadata.accountId` + * set, then resolve their account_emails in one Supabase query. Streaming + * keeps memory flat — we only retain the deduped account_id strings, not + * full Subscription objects. */ export async function getSubscriberAccountEmails() { - const activeSubscriptions = await getActiveSubscriptions(); + const accountIds = new Set(); + for await (const subscription of iterateActiveSubscriptions()) { + const id = subscription.metadata?.accountId; + if (id) accountIds.add(id); + } - const accountIds = activeSubscriptions - .map(subscription => subscription.metadata?.accountId) - .filter((accountId): accountId is string => Boolean(accountId)); + if (accountIds.size === 0) return []; - if (accountIds.length === 0) return []; - - return selectAccountEmails({ accountIds }); + return selectAccountEmails({ accountIds: Array.from(accountIds) }); } diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts deleted file mode 100644 index c728b435e..000000000 --- a/lib/stripe/getActiveSubscriptions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Stripe from "stripe"; -import stripeClient from "@/lib/stripe/client"; - -/** - * Fetch all active Stripe subscriptions via cursor pagination (Stripe's async - * iterator). Filters on `current_period_end > now` to preserve the legacy - * semantics (active includes in-grace-period). Returns [] on API error rather - * than throwing — callers treat empty as "no subscribers" and degrade safely. - */ -export async function getActiveSubscriptions(): Promise { - try { - const nowSec = Math.floor(Date.now() / 1000); - return await stripeClient() - .subscriptions.list({ limit: 100, current_period_end: { gt: nowSec } }) - .autoPagingToArray({ limit: 10000 }); - } catch (error) { - console.error("[ERROR] getActiveSubscriptions:", error); - return []; - } -} diff --git a/lib/stripe/iterateActiveSubscriptions.ts b/lib/stripe/iterateActiveSubscriptions.ts new file mode 100644 index 000000000..9d6c74b54 --- /dev/null +++ b/lib/stripe/iterateActiveSubscriptions.ts @@ -0,0 +1,25 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Stream active Stripe subscriptions via cursor pagination. `status: "active"` + * is Stripe's native filter — narrower and less error-prone than comparing + * `current_period_end` to server clock (timezone / clock-skew hazards). + * + * Yielded as an async iterable so callers can fold results incrementally + * (e.g. pluck `metadata.accountId` into a Set) instead of materializing the + * whole list in memory. Errors are logged and end iteration — callers treat + * a short iterator as "no subscribers" and degrade safely. + */ +export async function* iterateActiveSubscriptions(): AsyncGenerator { + try { + for await (const sub of stripeClient().subscriptions.list({ + status: "active", + limit: 100, + })) { + yield sub; + } + } catch (error) { + console.error("[ERROR] iterateActiveSubscriptions:", error); + } +} From 22a60253f4809897f6074f7e84bce135133335fb Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:47:15 +0530 Subject: [PATCH 07/13] refactor: separate stripe from enterprise; rename merge to getProArtists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/stripe/ now owns getActiveSubscriptions + getSubscriberAccountEmails (they query Stripe, not enterprise domains). - lib/enterprise/ keeps only ENTERPRISE_DOMAINS and the domain email fan-out. - The merge orchestrator moves to lib/artists/getProArtists.ts (enterprise ∪ subscribers); renamed from getEnterpriseArtists since 'enterprise artists' understates what it returns. - getActiveSubscriptions uses a manual starting_after cursor loop so there is no iterator and no hard row cap. --- .../__tests__/getArtistsProHandler.test.ts | 14 ++++----- lib/artists/getArtistsProHandler.ts | 4 +-- .../getProArtists.ts} | 4 +-- lib/enterprise/getAllEnterpriseAccounts.ts | 4 +-- lib/enterprise/getSubscriberAccountEmails.ts | 20 ------------ lib/stripe/getActiveSubscriptions.ts | 31 +++++++++++++++++++ lib/stripe/getSubscriberAccountEmails.ts | 22 +++++++++++++ lib/stripe/iterateActiveSubscriptions.ts | 25 --------------- 8 files changed, 66 insertions(+), 58 deletions(-) rename lib/{enterprise/getEnterpriseArtists.ts => artists/getProArtists.ts} (87%) delete mode 100644 lib/enterprise/getSubscriberAccountEmails.ts create mode 100644 lib/stripe/getActiveSubscriptions.ts create mode 100644 lib/stripe/getSubscriberAccountEmails.ts delete mode 100644 lib/stripe/iterateActiveSubscriptions.ts diff --git a/lib/artists/__tests__/getArtistsProHandler.test.ts b/lib/artists/__tests__/getArtistsProHandler.test.ts index 77b3d4377..7fa60e3cd 100644 --- a/lib/artists/__tests__/getArtistsProHandler.test.ts +++ b/lib/artists/__tests__/getArtistsProHandler.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; import { getArtistsProHandler } from "../getArtistsProHandler"; import { validateGetArtistsProRequest } from "../validateGetArtistsProRequest"; -import { getEnterpriseArtists } from "@/lib/enterprise/getEnterpriseArtists"; +import { getProArtists } from "@/lib/artists/getProArtists"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -12,8 +12,8 @@ vi.mock("../validateGetArtistsProRequest", () => ({ validateGetArtistsProRequest: vi.fn(), })); -vi.mock("@/lib/enterprise/getEnterpriseArtists", () => ({ - getEnterpriseArtists: vi.fn(), +vi.mock("@/lib/artists/getProArtists", () => ({ + getProArtists: vi.fn(), })); describe("getArtistsProHandler", () => { @@ -30,7 +30,7 @@ describe("getArtistsProHandler", () => { ["both enterprise and subscription, deduped", ["a1", "b1", "c1"]], ["neither — empty", []], ])("returns %s artists on 200", async (_label, artists) => { - vi.mocked(getEnterpriseArtists).mockResolvedValue(artists); + vi.mocked(getProArtists).mockResolvedValue(artists); const response = await getArtistsProHandler(request); const body = await response.json(); @@ -46,7 +46,7 @@ describe("getArtistsProHandler", () => { const response = await getArtistsProHandler(request); expect(response).toBe(err); - expect(getEnterpriseArtists).not.toHaveBeenCalled(); + expect(getProArtists).not.toHaveBeenCalled(); }); it("propagates the 403 response for non-admin callers", async () => { @@ -56,11 +56,11 @@ describe("getArtistsProHandler", () => { const response = await getArtistsProHandler(request); expect(response).toBe(err); - expect(getEnterpriseArtists).not.toHaveBeenCalled(); + expect(getProArtists).not.toHaveBeenCalled(); }); it("returns a generic 500 without leaking error details", async () => { - vi.mocked(getEnterpriseArtists).mockRejectedValue( + vi.mocked(getProArtists).mockRejectedValue( new Error("db.internal.host=10.0.0.5 connection refused"), ); diff --git a/lib/artists/getArtistsProHandler.ts b/lib/artists/getArtistsProHandler.ts index a8e73ee22..9036cb250 100644 --- a/lib/artists/getArtistsProHandler.ts +++ b/lib/artists/getArtistsProHandler.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { getEnterpriseArtists } from "@/lib/enterprise/getEnterpriseArtists"; +import { getProArtists } from "@/lib/artists/getProArtists"; import { validateGetArtistsProRequest } from "@/lib/artists/validateGetArtistsProRequest"; export async function getArtistsProHandler(request: NextRequest): Promise { @@ -10,7 +10,7 @@ export async function getArtistsProHandler(request: NextRequest): Promise { +export async function getProArtists(): Promise { const [enterpriseEmails, subscriberEmails] = await Promise.all([ getAllEnterpriseAccounts(), getSubscriberAccountEmails(), diff --git a/lib/enterprise/getAllEnterpriseAccounts.ts b/lib/enterprise/getAllEnterpriseAccounts.ts index f8a2aac57..d35ee6be6 100644 --- a/lib/enterprise/getAllEnterpriseAccounts.ts +++ b/lib/enterprise/getAllEnterpriseAccounts.ts @@ -3,8 +3,8 @@ import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmai /** * Fan-out an ilike query per enterprise domain in parallel and flatten the - * results. Not deduped here — downstream `getEnterpriseArtists` merges with - * the subscriber set and dedupes on `account_id`. + * results. Not deduped here — downstream `getProArtists` merges with the + * subscriber set and dedupes on `account_id`. */ export async function getAllEnterpriseAccounts() { const emailArrays = await Promise.all( diff --git a/lib/enterprise/getSubscriberAccountEmails.ts b/lib/enterprise/getSubscriberAccountEmails.ts deleted file mode 100644 index 5539074e2..000000000 --- a/lib/enterprise/getSubscriberAccountEmails.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { iterateActiveSubscriptions } from "@/lib/stripe/iterateActiveSubscriptions"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -/** - * Stream active Stripe subscriptions and collect the `metadata.accountId` - * set, then resolve their account_emails in one Supabase query. Streaming - * keeps memory flat — we only retain the deduped account_id strings, not - * full Subscription objects. - */ -export async function getSubscriberAccountEmails() { - const accountIds = new Set(); - for await (const subscription of iterateActiveSubscriptions()) { - const id = subscription.metadata?.accountId; - if (id) accountIds.add(id); - } - - if (accountIds.size === 0) return []; - - return selectAccountEmails({ accountIds: Array.from(accountIds) }); -} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..b4cb20456 --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,31 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +/** + * Fetch all active Stripe subscriptions via manual cursor pagination. Uses + * Stripe's native `status: "active"` filter rather than a `current_period_end` + * comparison (which is prone to clock-skew and timezone bugs). Loops until + * `has_more` is false — no hard cap on the active subscription count. Returns + * [] on API error so callers treat it as "no subscribers" and degrade safely. + */ +export async function getActiveSubscriptions(): Promise { + const results: Stripe.Subscription[] = []; + let startingAfter: string | undefined; + + try { + for (;;) { + const page = await stripeClient().subscriptions.list({ + status: "active", + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + results.push(...page.data); + if (!page.has_more || page.data.length === 0) break; + startingAfter = page.data[page.data.length - 1].id; + } + } catch (error) { + console.error("[ERROR] getActiveSubscriptions:", error); + } + + return results; +} diff --git a/lib/stripe/getSubscriberAccountEmails.ts b/lib/stripe/getSubscriberAccountEmails.ts new file mode 100644 index 000000000..615a2ef69 --- /dev/null +++ b/lib/stripe/getSubscriberAccountEmails.ts @@ -0,0 +1,22 @@ +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolve subscriber account_ids from active Stripe subscription metadata and + * look up their account_emails in one Supabase query. + */ +export async function getSubscriberAccountEmails() { + const activeSubscriptions = await getActiveSubscriptions(); + + const accountIds = Array.from( + new Set( + activeSubscriptions + .map(subscription => subscription.metadata?.accountId) + .filter((accountId): accountId is string => Boolean(accountId)), + ), + ); + + if (accountIds.length === 0) return []; + + return selectAccountEmails({ accountIds }); +} diff --git a/lib/stripe/iterateActiveSubscriptions.ts b/lib/stripe/iterateActiveSubscriptions.ts deleted file mode 100644 index 9d6c74b54..000000000 --- a/lib/stripe/iterateActiveSubscriptions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import Stripe from "stripe"; -import stripeClient from "@/lib/stripe/client"; - -/** - * Stream active Stripe subscriptions via cursor pagination. `status: "active"` - * is Stripe's native filter — narrower and less error-prone than comparing - * `current_period_end` to server clock (timezone / clock-skew hazards). - * - * Yielded as an async iterable so callers can fold results incrementally - * (e.g. pluck `metadata.accountId` into a Set) instead of materializing the - * whole list in memory. Errors are logged and end iteration — callers treat - * a short iterator as "no subscribers" and degrade safely. - */ -export async function* iterateActiveSubscriptions(): AsyncGenerator { - try { - for await (const sub of stripeClient().subscriptions.list({ - status: "active", - limit: 100, - })) { - yield sub; - } - } catch (error) { - console.error("[ERROR] iterateActiveSubscriptions:", error); - } -} From 235797a92e4b721503307ad233bca509e96287cf Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:55:45 +0530 Subject: [PATCH 08/13] refactor: project pro sources to account_ids, skip subscriber email round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscriber path fetched Stripe subs, extracted metadata.accountId, round-tripped Supabase to get account_emails rows, then unpacked account_id again — the email lookup was inert. - lib/stripe/getSubscriberAccountIds reads metadata.accountId from Stripe directly; no Supabase call. - lib/enterprise/getEnterpriseAccountIds projects the ilike result to account_ids at the source. - getProArtists now merges two string arrays, dedupes once, then hits account_artist_ids. --- lib/artists/getProArtists.ts | 31 +++++++++------------- lib/enterprise/getAllEnterpriseAccounts.ts | 14 ---------- lib/enterprise/getEnterpriseAccountIds.ts | 18 +++++++++++++ lib/stripe/getSubscriberAccountEmails.ts | 22 --------------- lib/stripe/getSubscriberAccountIds.ts | 14 ++++++++++ 5 files changed, 45 insertions(+), 54 deletions(-) delete mode 100644 lib/enterprise/getAllEnterpriseAccounts.ts create mode 100644 lib/enterprise/getEnterpriseAccountIds.ts delete mode 100644 lib/stripe/getSubscriberAccountEmails.ts create mode 100644 lib/stripe/getSubscriberAccountIds.ts diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts index 663f9ff9f..b7587f23c 100644 --- a/lib/artists/getProArtists.ts +++ b/lib/artists/getProArtists.ts @@ -1,29 +1,24 @@ -import { getAllEnterpriseAccounts } from "@/lib/enterprise/getAllEnterpriseAccounts"; -import { getSubscriberAccountEmails } from "@/lib/stripe/getSubscriberAccountEmails"; +import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; +import { getSubscriberAccountIds } from "@/lib/stripe/getSubscriberAccountIds"; import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; /** - * Resolve the deduped list of artist_ids owned by "pro" accounts — enterprise - * email domain or active Stripe subscription. Dedupes at both layers - * (account_id then artist_id) so accounts present in both sources and artists - * shared across accounts do not produce cartesian-product rows. Short-circuits - * when no pro accounts are found before hitting `account_artist_ids`. + * Deduped artist_ids owned by "pro" accounts — enterprise email domain OR + * active Stripe subscription. Dedupes at both layers (account_id then + * artist_id) so accounts present in both sources and artists shared across + * accounts do not produce cartesian-product rows. Short-circuits before the + * account_artist_ids query when no pro accounts are found. */ export async function getProArtists(): Promise { - const [enterpriseEmails, subscriberEmails] = await Promise.all([ - getAllEnterpriseAccounts(), - getSubscriberAccountEmails(), + const [enterpriseIds, subscriberIds] = await Promise.all([ + getEnterpriseAccountIds(), + getSubscriberAccountIds(), ]); - const accountIds = new Set( - [...enterpriseEmails, ...subscriberEmails] - .map(row => row.account_id) - .filter((id): id is string => Boolean(id)), - ); - - if (accountIds.size === 0) return []; + const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); + if (accountIds.length === 0) return []; - const artistIdRows = await selectAccountArtistIds(Array.from(accountIds)); + const artistIdRows = await selectAccountArtistIds(accountIds); return Array.from( new Set(artistIdRows.map(row => row.artist_id).filter((id): id is string => Boolean(id))), diff --git a/lib/enterprise/getAllEnterpriseAccounts.ts b/lib/enterprise/getAllEnterpriseAccounts.ts deleted file mode 100644 index d35ee6be6..000000000 --- a/lib/enterprise/getAllEnterpriseAccounts.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -/** - * Fan-out an ilike query per enterprise domain in parallel and flatten the - * results. Not deduped here — downstream `getProArtists` merges with the - * subscriber set and dedupes on `account_id`. - */ -export async function getAllEnterpriseAccounts() { - const emailArrays = await Promise.all( - Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmails({ domain })), - ); - return emailArrays.flat(); -} diff --git a/lib/enterprise/getEnterpriseAccountIds.ts b/lib/enterprise/getEnterpriseAccountIds.ts new file mode 100644 index 000000000..fb2f9a74b --- /dev/null +++ b/lib/enterprise/getEnterpriseAccountIds.ts @@ -0,0 +1,18 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolve account_ids whose email belongs to an enterprise domain. Fans out + * one ilike query per domain in parallel and projects down to account_ids — + * the email rows themselves aren't consumed downstream. + */ +export async function getEnterpriseAccountIds(): Promise { + const rowsPerDomain = await Promise.all( + Array.from(ENTERPRISE_DOMAINS).map(domain => selectAccountEmails({ domain })), + ); + + return rowsPerDomain + .flat() + .map(row => row.account_id) + .filter((id): id is string => Boolean(id)); +} diff --git a/lib/stripe/getSubscriberAccountEmails.ts b/lib/stripe/getSubscriberAccountEmails.ts deleted file mode 100644 index 615a2ef69..000000000 --- a/lib/stripe/getSubscriberAccountEmails.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -/** - * Resolve subscriber account_ids from active Stripe subscription metadata and - * look up their account_emails in one Supabase query. - */ -export async function getSubscriberAccountEmails() { - const activeSubscriptions = await getActiveSubscriptions(); - - const accountIds = Array.from( - new Set( - activeSubscriptions - .map(subscription => subscription.metadata?.accountId) - .filter((accountId): accountId is string => Boolean(accountId)), - ), - ); - - if (accountIds.length === 0) return []; - - return selectAccountEmails({ accountIds }); -} diff --git a/lib/stripe/getSubscriberAccountIds.ts b/lib/stripe/getSubscriberAccountIds.ts new file mode 100644 index 000000000..8efb17bfa --- /dev/null +++ b/lib/stripe/getSubscriberAccountIds.ts @@ -0,0 +1,14 @@ +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; + +/** + * Read subscriber account_ids straight from active Stripe subscription + * metadata. The `accountId` is stamped onto subscriptions at checkout, so no + * Supabase round-trip is needed. + */ +export async function getSubscriberAccountIds(): Promise { + const activeSubscriptions = await getActiveSubscriptions(); + + return activeSubscriptions + .map(subscription => subscription.metadata?.accountId) + .filter((accountId): accountId is string => Boolean(accountId)); +} From 339808d51955d58dc8fa51e87163830ce840a994 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:58:00 +0530 Subject: [PATCH 09/13] refactor: inline subscriber accountId extraction into getProArtists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSubscriberAccountIds was a two-line .map/.filter wrapper. Drop it and inline against the Stripe result — no useful indirection. --- lib/artists/getProArtists.ts | 10 +++++++--- lib/stripe/getSubscriberAccountIds.ts | 14 -------------- 2 files changed, 7 insertions(+), 17 deletions(-) delete mode 100644 lib/stripe/getSubscriberAccountIds.ts diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts index b7587f23c..3522bb26a 100644 --- a/lib/artists/getProArtists.ts +++ b/lib/artists/getProArtists.ts @@ -1,5 +1,5 @@ import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; -import { getSubscriberAccountIds } from "@/lib/stripe/getSubscriberAccountIds"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; /** @@ -10,11 +10,15 @@ import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/select * account_artist_ids query when no pro accounts are found. */ export async function getProArtists(): Promise { - const [enterpriseIds, subscriberIds] = await Promise.all([ + const [enterpriseIds, activeSubscriptions] = await Promise.all([ getEnterpriseAccountIds(), - getSubscriberAccountIds(), + getActiveSubscriptions(), ]); + const subscriberIds = activeSubscriptions + .map(subscription => subscription.metadata?.accountId) + .filter((id): id is string => Boolean(id)); + const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); if (accountIds.length === 0) return []; diff --git a/lib/stripe/getSubscriberAccountIds.ts b/lib/stripe/getSubscriberAccountIds.ts deleted file mode 100644 index 8efb17bfa..000000000 --- a/lib/stripe/getSubscriberAccountIds.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; - -/** - * Read subscriber account_ids straight from active Stripe subscription - * metadata. The `accountId` is stamped onto subscriptions at checkout, so no - * Supabase round-trip is needed. - */ -export async function getSubscriberAccountIds(): Promise { - const activeSubscriptions = await getActiveSubscriptions(); - - return activeSubscriptions - .map(subscription => subscription.metadata?.accountId) - .filter((accountId): accountId is string => Boolean(accountId)); -} From 299d2f1fe541eb7f4e8a39bb8bbc4e42196100e5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 20:59:46 +0530 Subject: [PATCH 10/13] refactor(stripe): return account_ids directly, skip Subscription hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getActiveSubscriptions returned full Subscription objects only to have every caller project down to metadata.accountId. Collapse that into getActiveSubscriptionAccountIds — strings in, strings out. --- lib/artists/getProArtists.ts | 10 ++---- lib/stripe/getActiveSubscriptionAccountIds.ts | 33 +++++++++++++++++++ lib/stripe/getActiveSubscriptions.ts | 31 ----------------- 3 files changed, 36 insertions(+), 38 deletions(-) create mode 100644 lib/stripe/getActiveSubscriptionAccountIds.ts delete mode 100644 lib/stripe/getActiveSubscriptions.ts diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts index 3522bb26a..a3d6e54aa 100644 --- a/lib/artists/getProArtists.ts +++ b/lib/artists/getProArtists.ts @@ -1,5 +1,5 @@ import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; -import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; +import { getActiveSubscriptionAccountIds } from "@/lib/stripe/getActiveSubscriptionAccountIds"; import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; /** @@ -10,15 +10,11 @@ import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/select * account_artist_ids query when no pro accounts are found. */ export async function getProArtists(): Promise { - const [enterpriseIds, activeSubscriptions] = await Promise.all([ + const [enterpriseIds, subscriberIds] = await Promise.all([ getEnterpriseAccountIds(), - getActiveSubscriptions(), + getActiveSubscriptionAccountIds(), ]); - const subscriberIds = activeSubscriptions - .map(subscription => subscription.metadata?.accountId) - .filter((id): id is string => Boolean(id)); - const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); if (accountIds.length === 0) return []; diff --git a/lib/stripe/getActiveSubscriptionAccountIds.ts b/lib/stripe/getActiveSubscriptionAccountIds.ts new file mode 100644 index 000000000..2d87f31a2 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionAccountIds.ts @@ -0,0 +1,33 @@ +import stripeClient from "@/lib/stripe/client"; + +/** + * Return the account_ids stamped on active Stripe subscriptions. Paginates + * via `starting_after` until `has_more` is false, so there's no hard cap on + * the active subscription count. `status: "active"` is Stripe's native + * filter — avoids the clock-skew hazard of comparing `current_period_end` + * against server time. Returns [] on API error so callers degrade safely. + */ +export async function getActiveSubscriptionAccountIds(): Promise { + const accountIds: string[] = []; + let startingAfter: string | undefined; + + try { + for (;;) { + const page = await stripeClient().subscriptions.list({ + status: "active", + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + for (const sub of page.data) { + const id = sub.metadata?.accountId; + if (id) accountIds.push(id); + } + if (!page.has_more || page.data.length === 0) break; + startingAfter = page.data[page.data.length - 1].id; + } + } catch (error) { + console.error("[ERROR] getActiveSubscriptionAccountIds:", error); + } + + return accountIds; +} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts deleted file mode 100644 index b4cb20456..000000000 --- a/lib/stripe/getActiveSubscriptions.ts +++ /dev/null @@ -1,31 +0,0 @@ -import Stripe from "stripe"; -import stripeClient from "@/lib/stripe/client"; - -/** - * Fetch all active Stripe subscriptions via manual cursor pagination. Uses - * Stripe's native `status: "active"` filter rather than a `current_period_end` - * comparison (which is prone to clock-skew and timezone bugs). Loops until - * `has_more` is false — no hard cap on the active subscription count. Returns - * [] on API error so callers treat it as "no subscribers" and degrade safely. - */ -export async function getActiveSubscriptions(): Promise { - const results: Stripe.Subscription[] = []; - let startingAfter: string | undefined; - - try { - for (;;) { - const page = await stripeClient().subscriptions.list({ - status: "active", - limit: 100, - ...(startingAfter ? { starting_after: startingAfter } : {}), - }); - results.push(...page.data); - if (!page.has_more || page.data.length === 0) break; - startingAfter = page.data[page.data.length - 1].id; - } - } catch (error) { - console.error("[ERROR] getActiveSubscriptions:", error); - } - - return results; -} From 3e6811453e607d1389055b5e554986a00d0a814e Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 21:15:36 +0530 Subject: [PATCH 11/13] refactor: query local subscriptions mirror instead of Stripe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase has a subscriptions table mirroring billing-provider state (stripe/lemon-squeezy/paddle) with account_id + active flag. Read from that and skip Stripe entirely — no pagination, no secret-key env var, no provider round-trip, and cross-provider correct. - lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts - lib/stripe/ removed - stripe package uninstalled getProArtists now merges two Supabase queries. --- lib/artists/getProArtists.ts | 4 +- lib/stripe/client.ts | 40 ------------------- lib/stripe/getActiveSubscriptionAccountIds.ts | 33 --------------- .../selectActiveSubscriptionAccountIds.ts | 21 ++++++++++ package.json | 1 - pnpm-lock.yaml | 12 ------ 6 files changed, 23 insertions(+), 88 deletions(-) delete mode 100644 lib/stripe/client.ts delete mode 100644 lib/stripe/getActiveSubscriptionAccountIds.ts create mode 100644 lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts index a3d6e54aa..a85f2c757 100644 --- a/lib/artists/getProArtists.ts +++ b/lib/artists/getProArtists.ts @@ -1,5 +1,5 @@ import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; -import { getActiveSubscriptionAccountIds } from "@/lib/stripe/getActiveSubscriptionAccountIds"; +import { selectActiveSubscriptionAccountIds } from "@/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds"; import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; /** @@ -12,7 +12,7 @@ import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/select export async function getProArtists(): Promise { const [enterpriseIds, subscriberIds] = await Promise.all([ getEnterpriseAccountIds(), - getActiveSubscriptionAccountIds(), + selectActiveSubscriptionAccountIds(), ]); const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts deleted file mode 100644 index 483399ae1..000000000 --- a/lib/stripe/client.ts +++ /dev/null @@ -1,40 +0,0 @@ -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; - -/** - * Lazy Stripe client. Do not throw at module-load: Next.js evaluates this - * module during `next build` (on Vercel, with `NODE_ENV=production`) while - * collecting page data, and the preview env may not have the secret wired - * up yet. Throwing here would fail the build before any request runs. - * - * Instead, defer the missing-secret check to the first call; the handler - * catches and maps to a generic 500, which is the right shape for a - * misconfigured environment. - */ -let cachedClient: Stripe | null = null; - -function stripeClient(): Stripe { - if (cachedClient) return cachedClient; - - const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK; - - if (!stripeSecretKey) { - throw new Error( - "Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.", - ); - } - - cachedClient = new Stripe(stripeSecretKey, { - apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, - }); - - return cachedClient; -} - -export default stripeClient; diff --git a/lib/stripe/getActiveSubscriptionAccountIds.ts b/lib/stripe/getActiveSubscriptionAccountIds.ts deleted file mode 100644 index 2d87f31a2..000000000 --- a/lib/stripe/getActiveSubscriptionAccountIds.ts +++ /dev/null @@ -1,33 +0,0 @@ -import stripeClient from "@/lib/stripe/client"; - -/** - * Return the account_ids stamped on active Stripe subscriptions. Paginates - * via `starting_after` until `has_more` is false, so there's no hard cap on - * the active subscription count. `status: "active"` is Stripe's native - * filter — avoids the clock-skew hazard of comparing `current_period_end` - * against server time. Returns [] on API error so callers degrade safely. - */ -export async function getActiveSubscriptionAccountIds(): Promise { - const accountIds: string[] = []; - let startingAfter: string | undefined; - - try { - for (;;) { - const page = await stripeClient().subscriptions.list({ - status: "active", - limit: 100, - ...(startingAfter ? { starting_after: startingAfter } : {}), - }); - for (const sub of page.data) { - const id = sub.metadata?.accountId; - if (id) accountIds.push(id); - } - if (!page.has_more || page.data.length === 0) break; - startingAfter = page.data[page.data.length - 1].id; - } - } catch (error) { - console.error("[ERROR] getActiveSubscriptionAccountIds:", error); - } - - return accountIds; -} diff --git a/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts b/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts new file mode 100644 index 000000000..38834d4d9 --- /dev/null +++ b/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Return account_ids that currently hold an active subscription. Reads the + * local `subscriptions` mirror rather than calling Stripe — the mirror is + * the source of truth for cross-provider billing state (stripe, + * lemon-squeezy, paddle) and avoids a paginated provider round-trip. + */ +export async function selectActiveSubscriptionAccountIds(): Promise { + const { data, error } = await supabase + .from("subscriptions") + .select("account_id") + .eq("active", true); + + if (error) { + console.error("Error fetching active subscription account_ids:", error); + return []; + } + + return (data ?? []).map(row => row.account_id).filter((id): id is string => Boolean(id)); +} diff --git a/package.json b/package.json index 173d6d416..5d12b8b07 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "react-dom": "^19.2.1", "resend": "^6.6.0", "sharp": "^0.34.5", - "stripe": "^17.7.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 97404e69c..72b683bc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,9 +125,6 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 - stripe: - specifier: ^17.7.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) @@ -5823,10 +5820,6 @@ 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'} @@ -14193,11 +14186,6 @@ 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 5947a3d77ced2d4832f9eb28a418df5a7d98e87a Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Fri, 24 Apr 2026 23:58:53 +0530 Subject: [PATCH 12/13] refactor: move route to /api/admins/artists/pro --- app/api/{ => admins}/artists/pro/route.ts | 2 +- lib/artists/__tests__/getArtistsProHandler.test.ts | 2 +- lib/artists/__tests__/validateGetArtistsProRequest.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename app/api/{ => admins}/artists/pro/route.ts (96%) diff --git a/app/api/artists/pro/route.ts b/app/api/admins/artists/pro/route.ts similarity index 96% rename from app/api/artists/pro/route.ts rename to app/api/admins/artists/pro/route.ts index a6036685c..3375ff910 100644 --- a/app/api/artists/pro/route.ts +++ b/app/api/admins/artists/pro/route.ts @@ -15,7 +15,7 @@ export async function OPTIONS() { } /** - * GET /api/artists/pro + * GET /api/admins/artists/pro * * Returns a deduplicated list of artist IDs owned by "pro" accounts * (enterprise-domain emails or active Stripe subscriptions). Admin-scoped — diff --git a/lib/artists/__tests__/getArtistsProHandler.test.ts b/lib/artists/__tests__/getArtistsProHandler.test.ts index 7fa60e3cd..638f5be41 100644 --- a/lib/artists/__tests__/getArtistsProHandler.test.ts +++ b/lib/artists/__tests__/getArtistsProHandler.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/artists/getProArtists", () => ({ })); describe("getArtistsProHandler", () => { - const request = new NextRequest("http://localhost/api/artists/pro"); + const request = new NextRequest("http://localhost/api/admins/artists/pro"); beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/artists/__tests__/validateGetArtistsProRequest.test.ts b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts index 066ebc290..ecad010ed 100644 --- a/lib/artists/__tests__/validateGetArtistsProRequest.test.ts +++ b/lib/artists/__tests__/validateGetArtistsProRequest.test.ts @@ -12,7 +12,7 @@ vi.mock("@/lib/admins/validateAdminAuth", () => ({ })); describe("validateGetArtistsProRequest", () => { - const request = new NextRequest("http://localhost/api/artists/pro"); + const request = new NextRequest("http://localhost/api/admins/artists/pro"); beforeEach(() => { vi.clearAllMocks(); From 6b5057f91050ede5906a5cc2ba29c15e0a113501 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 25 Apr 2026 00:17:33 +0530 Subject: [PATCH 13/13] refactor: generalize to selectSubscriptions helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review — replace selectActiveSubscriptionAccountIds with a generic selectSubscriptions({ accountIds?, active? }) helper. Caller projects to account_ids. --- lib/artists/getProArtists.ts | 10 ++++-- .../selectActiveSubscriptionAccountIds.ts | 21 ----------- .../subscriptions/selectSubscriptions.ts | 35 +++++++++++++++++++ 3 files changed, 42 insertions(+), 24 deletions(-) delete mode 100644 lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts create mode 100644 lib/supabase/subscriptions/selectSubscriptions.ts diff --git a/lib/artists/getProArtists.ts b/lib/artists/getProArtists.ts index a85f2c757..35cdf3894 100644 --- a/lib/artists/getProArtists.ts +++ b/lib/artists/getProArtists.ts @@ -1,5 +1,5 @@ import { getEnterpriseAccountIds } from "@/lib/enterprise/getEnterpriseAccountIds"; -import { selectActiveSubscriptionAccountIds } from "@/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds"; +import selectSubscriptions from "@/lib/supabase/subscriptions/selectSubscriptions"; import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/selectAccountArtistIds"; /** @@ -10,11 +10,15 @@ import { selectAccountArtistIds } from "@/lib/supabase/account_artist_ids/select * account_artist_ids query when no pro accounts are found. */ export async function getProArtists(): Promise { - const [enterpriseIds, subscriberIds] = await Promise.all([ + const [enterpriseIds, subscriptionRows] = await Promise.all([ getEnterpriseAccountIds(), - selectActiveSubscriptionAccountIds(), + selectSubscriptions({ active: true }), ]); + const subscriberIds = subscriptionRows + .map(row => row.account_id) + .filter((id): id is string => Boolean(id)); + const accountIds = Array.from(new Set([...enterpriseIds, ...subscriberIds])); if (accountIds.length === 0) return []; diff --git a/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts b/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts deleted file mode 100644 index 38834d4d9..000000000 --- a/lib/supabase/subscriptions/selectActiveSubscriptionAccountIds.ts +++ /dev/null @@ -1,21 +0,0 @@ -import supabase from "@/lib/supabase/serverClient"; - -/** - * Return account_ids that currently hold an active subscription. Reads the - * local `subscriptions` mirror rather than calling Stripe — the mirror is - * the source of truth for cross-provider billing state (stripe, - * lemon-squeezy, paddle) and avoids a paginated provider round-trip. - */ -export async function selectActiveSubscriptionAccountIds(): Promise { - const { data, error } = await supabase - .from("subscriptions") - .select("account_id") - .eq("active", true); - - if (error) { - console.error("Error fetching active subscription account_ids:", error); - return []; - } - - return (data ?? []).map(row => row.account_id).filter((id): id is string => Boolean(id)); -} diff --git a/lib/supabase/subscriptions/selectSubscriptions.ts b/lib/supabase/subscriptions/selectSubscriptions.ts new file mode 100644 index 000000000..9acdf289e --- /dev/null +++ b/lib/supabase/subscriptions/selectSubscriptions.ts @@ -0,0 +1,35 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select subscription rows, optionally filtered by account IDs and/or active + * state. The `subscriptions` table is the local mirror of cross-provider + * billing state (stripe, lemon-squeezy, paddle). + */ +export default async function selectSubscriptions({ + accountIds, + active, +}: { + accountIds?: string | string[]; + active?: boolean; +} = {}): Promise[]> { + let query = supabase.from("subscriptions").select("*"); + + if (accountIds !== undefined) { + const ids = Array.isArray(accountIds) ? accountIds : [accountIds]; + query = query.in("account_id", ids); + } + + if (active !== undefined) { + query = query.eq("active", active); + } + + const { data, error } = await query; + + if (error) { + console.error("Error fetching subscriptions:", error); + return []; + } + + return data || []; +}