diff --git a/lib/credits/__tests__/const.test.ts b/lib/credits/__tests__/const.test.ts new file mode 100644 index 00000000..10438429 --- /dev/null +++ b/lib/credits/__tests__/const.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; + +describe("credit plan constants", () => { + it("keeps the free-tier allotment at 333 (matches chat/lib/consts.ts)", () => { + expect(DEFAULT_CREDITS).toBe(333); + }); + + it("gives pro accounts 9999 credits per month (matches chat/lib/consts.ts)", () => { + expect(PRO_CREDITS).toBe(9999); + }); +}); diff --git a/lib/credits/__tests__/getAccountSubscriptionState.test.ts b/lib/credits/__tests__/getAccountSubscriptionState.test.ts index 5c9e0d45..989b1c3b 100644 --- a/lib/credits/__tests__/getAccountSubscriptionState.test.ts +++ b/lib/credits/__tests__/getAccountSubscriptionState.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getAccountSubscriptionState } from "@/lib/credits/getAccountSubscriptionState"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { isEnterpriseAccount } from "@/lib/enterprise/isEnterpriseAccount"; vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ getActiveSubscriptionDetails: vi.fn(), @@ -12,11 +13,16 @@ vi.mock("@/lib/stripe/getOrgSubscription", () => ({ getOrgSubscription: vi.fn(), })); +vi.mock("@/lib/enterprise/isEnterpriseAccount", () => ({ + isEnterpriseAccount: vi.fn(), +})); + const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; describe("getAccountSubscriptionState", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isEnterpriseAccount).mockResolvedValue(false); }); it("returns isPro=false / activeSubscription=null when neither subscription is active", async () => { @@ -69,4 +75,30 @@ describe("getAccountSubscriptionState", () => { expect(result).toEqual({ isPro: false, activeSubscription: null }); }); + + it("returns isPro=true with activeSubscription=null for an enterprise account without Stripe", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isEnterpriseAccount).mockResolvedValue(true); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: true, activeSubscription: null }); + expect(isEnterpriseAccount).toHaveBeenCalledWith(ACCOUNT); + }); + + it("keeps activeSubscription Stripe-only when both enterprise and a Stripe sub apply", async () => { + const accountSub = { + id: "sub_account", + status: "active", + canceled_at: null, + } as never; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(accountSub); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(isEnterpriseAccount).mockResolvedValue(true); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: true, activeSubscription: accountSub }); + }); }); diff --git a/lib/credits/const.ts b/lib/credits/const.ts index 67b75527..13db892f 100644 --- a/lib/credits/const.ts +++ b/lib/credits/const.ts @@ -5,9 +5,11 @@ export const DEFAULT_CREDITS = 333; /** - * Monthly credit allotment for accounts on a pro plan (directly or via an organization). + * Monthly credit allotment for accounts on a pro plan (directly, via an + * organization, or via an enterprise email domain). Effectively "don't think + * about credits" for paying customers. */ -export const PRO_CREDITS = 1000; +export const PRO_CREDITS = 9999; /** * Credits added per automatic top-up when a request would push an account below diff --git a/lib/credits/getAccountSubscriptionState.ts b/lib/credits/getAccountSubscriptionState.ts index 9227d86f..fb9d4fa4 100644 --- a/lib/credits/getAccountSubscriptionState.ts +++ b/lib/credits/getAccountSubscriptionState.ts @@ -2,6 +2,7 @@ import type Stripe from "stripe"; import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { isEnterpriseAccount } from "@/lib/enterprise/isEnterpriseAccount"; export interface AccountSubscriptionState { isPro: boolean; @@ -9,25 +10,28 @@ export interface AccountSubscriptionState { } /** - * Single source of truth for "what's this account's plan?" — checks both the - * account-level subscription and any org membership in parallel, then resolves - * the active subscription with account-wins-on-tie precedence. + * Single source of truth for "what's this account's plan?" — checks the + * account-level subscription, any org membership, and enterprise email-domain + * status in parallel. `isPro` is true if any of the three match; the active + * subscription resolves with account-wins-on-tie precedence. * - * Returns the resolved Stripe.Subscription alongside the boolean so callers - * that need period-start timestamps (e.g. checkAndResetCredits) don't have to - * re-derive which one was active. + * `activeSubscription` stays Stripe-only on purpose: it feeds + * checkAndResetCredits's early-refill via `current_period_start`, and + * enterprise-domain accounts (no Stripe sub) must refill on the ≥1-month path + * only — so an enterprise match yields `isPro: true, activeSubscription: null`. */ export async function getAccountSubscriptionState( accountId: string, ): Promise { - const [accountSub, orgSub] = await Promise.all([ + const [accountSub, orgSub, isEnterprise] = await Promise.all([ getActiveSubscriptionDetails(accountId), getOrgSubscription(accountId), + isEnterpriseAccount(accountId), ]); const hasAccountSub = isActiveSubscription(accountSub); const hasOrgSub = isActiveSubscription(orgSub); return { - isPro: hasAccountSub || hasOrgSub, + isPro: hasAccountSub || hasOrgSub || isEnterprise, activeSubscription: hasAccountSub ? accountSub : hasOrgSub ? orgSub : null, }; } diff --git a/lib/enterprise/__tests__/isEnterpriseAccount.test.ts b/lib/enterprise/__tests__/isEnterpriseAccount.test.ts new file mode 100644 index 00000000..6ff52a74 --- /dev/null +++ b/lib/enterprise/__tests__/isEnterpriseAccount.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { isEnterpriseAccount } from "@/lib/enterprise/isEnterpriseAccount"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ + default: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +function emailRow(email: string | null) { + return { account_id: ACCOUNT, email, id: "row-1" } as never; +} + +describe("isEnterpriseAccount", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when an account email's domain is in ENTERPRISE_DOMAINS", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([emailRow("artist@seekermusic.com")]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(true); + expect(selectAccountEmails).toHaveBeenCalledWith({ accountIds: ACCOUNT }); + }); + + it("matches domains case-insensitively", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([emailRow("Artist@SeekerMusic.com")]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(true); + }); + + it("returns true when any of multiple emails matches", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([ + emailRow("personal@gmail.com"), + emailRow("work@rostrumrecords.com"), + ]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(true); + }); + + it("returns false when no email domain is enterprise", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([emailRow("someone@gmail.com")]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(false); + }); + + it("returns false when the account has no emails", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(false); + }); + + it("returns false for rows with null or malformed emails", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([emailRow(null), emailRow("not-an-email")]); + + await expect(isEnterpriseAccount(ACCOUNT)).resolves.toBe(false); + }); +}); diff --git a/lib/enterprise/isEnterpriseAccount.ts b/lib/enterprise/isEnterpriseAccount.ts new file mode 100644 index 00000000..e5866657 --- /dev/null +++ b/lib/enterprise/isEnterpriseAccount.ts @@ -0,0 +1,16 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/enterprise/consts"; +import { extractDomain } from "@/lib/email/extractDomain"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; + +/** + * Resolves whether an account belongs to an enterprise customer: true when any + * of the account's emails has a domain in ENTERPRISE_DOMAINS. Used by the + * credits system to grant pro status without a Stripe subscription. + */ +export async function isEnterpriseAccount(accountId: string): Promise { + const rows = await selectAccountEmails({ accountIds: accountId }); + return rows.some(row => { + const domain = row.email ? extractDomain(row.email) : null; + return domain !== null && ENTERPRISE_DOMAINS.has(domain); + }); +}