From da69293b012563c3f9b085f1c34f31b5c91c6de1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 11 May 2026 15:42:26 -0500 Subject: [PATCH] fix(credits): seed new accounts at the right plan-aware balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand-new accounts used to receive exactly 25 credits regardless of plan, because insertCreditsUsage had a hard-coded DEFAULT_CREDITS=25 fallback that both call sites (agent signup + account create) relied on. The new credits-balance endpoint exposes this as "25 / 333 used 308" the moment an account is provisioned. Fix at the API layer, reusing what PR #547 already gave us: - New `lib/credits/getAccountSubscriptionState.ts` — single source of truth for "is this account pro?". Extracts the parallel getActiveSubscriptionDetails + getOrgSubscription lookup that checkAndResetCredits already did inline. - `checkAndResetCredits` now delegates to that helper. Behavior unchanged; 7 lines collapse to 2. - New `lib/credits/initializeAccountCredits.ts` — plan-aware seeder. Looks up the subscription state via the new helper, then calls insertCreditsUsage with PRO_CREDITS=1000 or DEFAULT_CREDITS=333 (the constants we already exported in PR #547). - Both call sites swap insertCreditsUsage(id) for initializeAccountCredits(id): - lib/agents/createAccountWithEmail.ts - lib/accounts/createAccountHandler.ts - Remove the booby-trap default from insertCreditsUsage. The remainingCredits parameter is now required, so any new caller that forgets to pick a plan-aware value gets a type error. TDD: 4 new tests for getAccountSubscriptionState, 3 for initializeAccountCredits, full checkAndResetCredits suite migrated to mock the new helper instead of three Stripe functions. 234 tests green across 39 files. lint clean. No typecheck regressions in changed files (pre-existing AI-SDK type drift in getCreditUsage.test and handleChatCredits.test is unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/createAccountHandler.test.ts | 8 +- lib/accounts/createAccountHandler.ts | 4 +- .../__tests__/agentSignupHandler.test.ts | 4 +- .../__tests__/createAccountWithEmail.test.ts | 22 +++--- lib/agents/createAccountWithEmail.ts | 6 +- .../__tests__/checkAndResetCredits.test.ts | 67 ++++++++-------- .../getAccountSubscriptionState.test.ts | 72 ++++++++++++++++++ .../initializeAccountCredits.test.ts | 76 +++++++++++++++++++ lib/credits/checkAndResetCredits.ts | 17 ++--- lib/credits/getAccountSubscriptionState.ts | 33 ++++++++ lib/credits/initializeAccountCredits.ts | 19 +++++ .../credits_usage/insertCreditsUsage.ts | 14 ++-- 12 files changed, 265 insertions(+), 77 deletions(-) create mode 100644 lib/credits/__tests__/getAccountSubscriptionState.test.ts create mode 100644 lib/credits/__tests__/initializeAccountCredits.test.ts create mode 100644 lib/credits/getAccountSubscriptionState.ts create mode 100644 lib/credits/initializeAccountCredits.ts diff --git a/lib/accounts/__tests__/createAccountHandler.test.ts b/lib/accounts/__tests__/createAccountHandler.test.ts index 0ad4d2c7b..e4a3b6d46 100644 --- a/lib/accounts/__tests__/createAccountHandler.test.ts +++ b/lib/accounts/__tests__/createAccountHandler.test.ts @@ -7,7 +7,7 @@ const mockGetAccountWithDetails = vi.fn(); const mockInsertAccount = vi.fn(); const mockInsertAccountEmail = vi.fn(); const mockInsertAccountWallet = vi.fn(); -const mockInsertCreditsUsage = vi.fn(); +const mockInitializeAccountCredits = vi.fn(); const mockAssignAccountToOrg = vi.fn(); vi.mock("@/lib/supabase/account_emails/selectAccountByEmail", () => ({ @@ -34,8 +34,8 @@ vi.mock("@/lib/supabase/account_wallets/insertAccountWallet", () => ({ insertAccountWallet: (...args: unknown[]) => mockInsertAccountWallet(...args), })); -vi.mock("@/lib/supabase/credits_usage/insertCreditsUsage", () => ({ - insertCreditsUsage: (...args: unknown[]) => mockInsertCreditsUsage(...args), +vi.mock("@/lib/credits/initializeAccountCredits", () => ({ + initializeAccountCredits: (...args: unknown[]) => mockInitializeAccountCredits(...args), })); vi.mock("@/lib/organizations/assignAccountToOrg", () => ({ @@ -128,7 +128,7 @@ describe("createAccountHandler", () => { expect(mockInsertAccountEmail).toHaveBeenCalledWith("account-789", "new@example.com"); expect(mockAssignAccountToOrg).toHaveBeenCalledWith("account-789", "new@example.com"); expect(mockInsertAccountWallet).toHaveBeenCalledWith("account-789", "0xdef"); - expect(mockInsertCreditsUsage).toHaveBeenCalledWith("account-789"); + expect(mockInitializeAccountCredits).toHaveBeenCalledWith("account-789"); }); it("returns 400 when account creation fails", async () => { diff --git a/lib/accounts/createAccountHandler.ts b/lib/accounts/createAccountHandler.ts index 6f7578de6..e93c5fcc5 100644 --- a/lib/accounts/createAccountHandler.ts +++ b/lib/accounts/createAccountHandler.ts @@ -6,7 +6,7 @@ import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDet import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountEmail"; import { insertAccountWallet } from "@/lib/supabase/account_wallets/insertAccountWallet"; -import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { initializeAccountCredits } from "@/lib/credits/initializeAccountCredits"; import { assignAccountToOrg } from "@/lib/organizations/assignAccountToOrg"; import type { CreateAccountBody } from "./validateCreateAccountBody"; @@ -91,7 +91,7 @@ export async function createAccountHandler(body: CreateAccountBody): Promise ({ insertAccountEmail: vi.fn(() => ({ id: "ae_1" })), })); -vi.mock("@/lib/supabase/credits_usage/insertCreditsUsage", () => ({ - insertCreditsUsage: vi.fn(() => ({ id: "cu_1" })), +vi.mock("@/lib/credits/initializeAccountCredits", () => ({ + initializeAccountCredits: vi.fn(() => ({ id: "cu_1" })), })); vi.mock("@/lib/keys/generateApiKey", () => ({ diff --git a/lib/agents/__tests__/createAccountWithEmail.test.ts b/lib/agents/__tests__/createAccountWithEmail.test.ts index 5dba14dc4..425faec56 100644 --- a/lib/agents/__tests__/createAccountWithEmail.test.ts +++ b/lib/agents/__tests__/createAccountWithEmail.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { createAccountWithEmail } from "@/lib/agents/createAccountWithEmail"; import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountEmail"; -import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { initializeAccountCredits } from "@/lib/credits/initializeAccountCredits"; vi.mock("@/lib/supabase/accounts/insertAccount", () => ({ insertAccount: vi.fn(), @@ -12,8 +12,8 @@ vi.mock("@/lib/supabase/account_emails/insertAccountEmail", () => ({ insertAccountEmail: vi.fn(() => ({ id: "ae_1" })), })); -vi.mock("@/lib/supabase/credits_usage/insertCreditsUsage", () => ({ - insertCreditsUsage: vi.fn(() => ({ id: "cu_1" })), +vi.mock("@/lib/credits/initializeAccountCredits", () => ({ + initializeAccountCredits: vi.fn(() => ({ id: "cu_1" })), })); describe("createAccountWithEmail", () => { @@ -25,9 +25,9 @@ describe("createAccountWithEmail", () => { vi.mocked(insertAccountEmail).mockResolvedValue({ id: "ae_1", } as unknown as Awaited>); - vi.mocked(insertCreditsUsage).mockResolvedValue({ + vi.mocked(initializeAccountCredits).mockResolvedValue({ id: "cu_1", - } as unknown as Awaited>); + } as unknown as Awaited>); }); it("creates the account, inserts the email link and credits row, and returns the new account id", async () => { @@ -36,7 +36,7 @@ describe("createAccountWithEmail", () => { expect(result).toBe("acc_new"); expect(insertAccount).toHaveBeenCalledOnce(); expect(insertAccountEmail).toHaveBeenCalledWith("acc_new", "user@example.com"); - expect(insertCreditsUsage).toHaveBeenCalledWith("acc_new"); + expect(initializeAccountCredits).toHaveBeenCalledWith("acc_new"); }); it("throws when insertAccountEmail returns null so the caller cannot end up with an emailless account", async () => { @@ -47,11 +47,13 @@ describe("createAccountWithEmail", () => { await expect(createAccountWithEmail("user@example.com")).rejects.toThrow(/insertAccountEmail/); }); - it("throws when insertCreditsUsage returns null", async () => { - vi.mocked(insertCreditsUsage).mockResolvedValueOnce( - null as unknown as Awaited>, + it("throws when initializeAccountCredits returns null", async () => { + vi.mocked(initializeAccountCredits).mockResolvedValueOnce( + null as unknown as Awaited>, ); - await expect(createAccountWithEmail("user@example.com")).rejects.toThrow(/insertCreditsUsage/); + await expect(createAccountWithEmail("user@example.com")).rejects.toThrow( + /initializeAccountCredits/, + ); }); }); diff --git a/lib/agents/createAccountWithEmail.ts b/lib/agents/createAccountWithEmail.ts index 2ae486d78..666d60808 100644 --- a/lib/agents/createAccountWithEmail.ts +++ b/lib/agents/createAccountWithEmail.ts @@ -1,6 +1,6 @@ import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountEmail"; -import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { initializeAccountCredits } from "@/lib/credits/initializeAccountCredits"; /** * Creates a new account row and wires up its email link and credits usage @@ -27,9 +27,9 @@ export async function createAccountWithEmail(email: string): Promise { throw new Error("createAccountWithEmail: insertAccountEmail returned null"); } - const credits = await insertCreditsUsage(account.id); + const credits = await initializeAccountCredits(account.id); if (!credits) { - throw new Error("createAccountWithEmail: insertCreditsUsage returned null"); + throw new Error("createAccountWithEmail: initializeAccountCredits returned null"); } return account.id; diff --git a/lib/credits/__tests__/checkAndResetCredits.test.ts b/lib/credits/__tests__/checkAndResetCredits.test.ts index 6d8e66be6..c47e88060 100644 --- a/lib/credits/__tests__/checkAndResetCredits.test.ts +++ b/lib/credits/__tests__/checkAndResetCredits.test.ts @@ -3,8 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { checkAndResetCredits } from "@/lib/credits/checkAndResetCredits"; import { selectCreditsUsage } from "@/lib/supabase/credits_usage/selectCreditsUsage"; import { updateCreditsUsage } from "@/lib/supabase/credits_usage/updateCreditsUsage"; -import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; -import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { getAccountSubscriptionState } from "@/lib/credits/getAccountSubscriptionState"; import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; vi.mock("@/lib/supabase/credits_usage/selectCreditsUsage", () => ({ @@ -15,16 +14,32 @@ vi.mock("@/lib/supabase/credits_usage/updateCreditsUsage", () => ({ updateCreditsUsage: vi.fn(), })); -vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ - getActiveSubscriptionDetails: vi.fn(), -})); - -vi.mock("@/lib/stripe/getOrgSubscription", () => ({ - getOrgSubscription: vi.fn(), +vi.mock("@/lib/credits/getAccountSubscriptionState", () => ({ + getAccountSubscriptionState: vi.fn(), })); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; +const freeState = { isPro: false, activeSubscription: null }; +const proStateFromAccount = { + isPro: true, + activeSubscription: { + id: "sub_1", + status: "active", + canceled_at: null, + current_period_start: Math.floor(new Date("2026-04-15T00:00:00.000Z").getTime() / 1000), + } as never, +}; +const proStateFromOrgNewlySubscribed = { + isPro: true, + activeSubscription: { + id: "sub_org", + status: "active", + canceled_at: null, + current_period_start: Math.floor(new Date("2026-05-08T00:00:00.000Z").getTime() / 1000), + } as never, +}; + const baseRow = ( overrides: Partial<{ remaining_credits: number; timestamp: string | null }> = {}, ) => ({ @@ -44,8 +59,7 @@ describe("checkAndResetCredits", () => { it("returns { creditsUsage: null, isPro: false } when no credits row exists", async () => { vi.mocked(selectCreditsUsage).mockResolvedValue([]); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(freeState); const result = await checkAndResetCredits(ACCOUNT); @@ -56,8 +70,7 @@ describe("checkAndResetCredits", () => { it("returns the row unchanged when it has no timestamp (never refilled)", async () => { const row = baseRow({ timestamp: null, remaining_credits: 200 }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(freeState); const result = await checkAndResetCredits(ACCOUNT); @@ -68,8 +81,7 @@ describe("checkAndResetCredits", () => { it("returns the row unchanged when last refill was within the past month and no new sub", async () => { const row = baseRow({ timestamp: "2026-05-01T00:00:00.000Z", remaining_credits: 150 }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(freeState); const result = await checkAndResetCredits(ACCOUNT); @@ -86,8 +98,7 @@ describe("checkAndResetCredits", () => { }; vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue(refilled); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(freeState); const result = await checkAndResetCredits(ACCOUNT); @@ -110,13 +121,7 @@ describe("checkAndResetCredits", () => { }; vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue(refilled); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ - id: "sub_1", - status: "active", - canceled_at: null, - current_period_start: Math.floor(new Date("2026-04-15T00:00:00.000Z").getTime() / 1000), - } as never); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(proStateFromAccount); const result = await checkAndResetCredits(ACCOUNT); @@ -139,13 +144,7 @@ describe("checkAndResetCredits", () => { }; vi.mocked(selectCreditsUsage).mockResolvedValue([row]); vi.mocked(updateCreditsUsage).mockResolvedValue(refilled); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); - vi.mocked(getOrgSubscription).mockResolvedValue({ - id: "sub_org", - status: "active", - canceled_at: null, - current_period_start: Math.floor(new Date("2026-05-08T00:00:00.000Z").getTime() / 1000), - } as never); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(proStateFromOrgNewlySubscribed); const result = await checkAndResetCredits(ACCOUNT); @@ -157,13 +156,7 @@ describe("checkAndResetCredits", () => { it("reports isPro=true without refilling when sub is active but neither refill trigger fires", async () => { const row = baseRow({ timestamp: "2026-05-01T00:00:00.000Z", remaining_credits: 800 }); vi.mocked(selectCreditsUsage).mockResolvedValue([row]); - vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ - id: "sub_1", - status: "active", - canceled_at: null, - current_period_start: Math.floor(new Date("2026-04-15T00:00:00.000Z").getTime() / 1000), - } as never); - vi.mocked(getOrgSubscription).mockResolvedValue(null); + vi.mocked(getAccountSubscriptionState).mockResolvedValue(proStateFromAccount); const result = await checkAndResetCredits(ACCOUNT); diff --git a/lib/credits/__tests__/getAccountSubscriptionState.test.ts b/lib/credits/__tests__/getAccountSubscriptionState.test.ts new file mode 100644 index 000000000..5c9e0d45b --- /dev/null +++ b/lib/credits/__tests__/getAccountSubscriptionState.test.ts @@ -0,0 +1,72 @@ +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"; + +vi.mock("@/lib/stripe/getActiveSubscriptionDetails", () => ({ + getActiveSubscriptionDetails: vi.fn(), +})); + +vi.mock("@/lib/stripe/getOrgSubscription", () => ({ + getOrgSubscription: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("getAccountSubscriptionState", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns isPro=false / activeSubscription=null when neither subscription is active", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: false, activeSubscription: null }); + }); + + it("returns isPro=true and prefers the account subscription when both are active", async () => { + const accountSub = { + id: "sub_account", + status: "active", + canceled_at: null, + } as never; + const orgSub = { id: "sub_org", status: "active", canceled_at: null } as never; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(accountSub); + vi.mocked(getOrgSubscription).mockResolvedValue(orgSub); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: true, activeSubscription: accountSub }); + }); + + it("falls back to the org subscription when only it is active", async () => { + const orgSub = { + id: "sub_org", + status: "trialing", + canceled_at: null, + } as never; + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue(null); + vi.mocked(getOrgSubscription).mockResolvedValue(orgSub); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: true, activeSubscription: orgSub }); + }); + + it("returns isPro=false when the account subscription exists but is canceled trialing", async () => { + vi.mocked(getActiveSubscriptionDetails).mockResolvedValue({ + id: "sub_account", + status: "trialing", + canceled_at: 1700000000, + } as never); + vi.mocked(getOrgSubscription).mockResolvedValue(null); + + const result = await getAccountSubscriptionState(ACCOUNT); + + expect(result).toEqual({ isPro: false, activeSubscription: null }); + }); +}); diff --git a/lib/credits/__tests__/initializeAccountCredits.test.ts b/lib/credits/__tests__/initializeAccountCredits.test.ts new file mode 100644 index 000000000..220fc8608 --- /dev/null +++ b/lib/credits/__tests__/initializeAccountCredits.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { initializeAccountCredits } from "@/lib/credits/initializeAccountCredits"; +import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { getAccountSubscriptionState } from "@/lib/credits/getAccountSubscriptionState"; +import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; + +vi.mock("@/lib/supabase/credits_usage/insertCreditsUsage", () => ({ + insertCreditsUsage: vi.fn(), +})); + +vi.mock("@/lib/credits/getAccountSubscriptionState", () => ({ + getAccountSubscriptionState: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("initializeAccountCredits", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("seeds DEFAULT_CREDITS for a free-tier account", async () => { + vi.mocked(getAccountSubscriptionState).mockResolvedValue({ + isPro: false, + activeSubscription: null, + }); + const inserted = { + id: 1, + account_id: ACCOUNT, + remaining_credits: DEFAULT_CREDITS, + timestamp: null, + }; + vi.mocked(insertCreditsUsage).mockResolvedValue(inserted); + + const result = await initializeAccountCredits(ACCOUNT); + + expect(insertCreditsUsage).toHaveBeenCalledWith(ACCOUNT, DEFAULT_CREDITS); + expect(result).toEqual(inserted); + }); + + it("seeds PRO_CREDITS when the account already has an active subscription", async () => { + vi.mocked(getAccountSubscriptionState).mockResolvedValue({ + isPro: true, + activeSubscription: { + id: "sub_1", + status: "active", + canceled_at: null, + } as never, + }); + const inserted = { + id: 2, + account_id: ACCOUNT, + remaining_credits: PRO_CREDITS, + timestamp: null, + }; + vi.mocked(insertCreditsUsage).mockResolvedValue(inserted); + + const result = await initializeAccountCredits(ACCOUNT); + + expect(insertCreditsUsage).toHaveBeenCalledWith(ACCOUNT, PRO_CREDITS); + expect(result).toEqual(inserted); + }); + + it("returns null when the underlying insert fails", async () => { + vi.mocked(getAccountSubscriptionState).mockResolvedValue({ + isPro: false, + activeSubscription: null, + }); + vi.mocked(insertCreditsUsage).mockResolvedValue(null); + + const result = await initializeAccountCredits(ACCOUNT); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/credits/checkAndResetCredits.ts b/lib/credits/checkAndResetCredits.ts index b455d1877..fa61726cb 100644 --- a/lib/credits/checkAndResetCredits.ts +++ b/lib/credits/checkAndResetCredits.ts @@ -3,9 +3,7 @@ import { type CreditsUsage, } from "@/lib/supabase/credits_usage/selectCreditsUsage"; import { updateCreditsUsage } from "@/lib/supabase/credits_usage/updateCreditsUsage"; -import isActiveSubscription from "@/lib/stripe/isActiveSubscription"; -import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; -import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { getAccountSubscriptionState } from "@/lib/credits/getAccountSubscriptionState"; import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; export interface CheckAndResetCreditsResult { @@ -21,16 +19,11 @@ export interface CheckAndResetCreditsResult { * Also returns `isPro` so callers don't need to repeat the subscription lookup. */ export async function checkAndResetCredits(accountId: string): Promise { - const [rows, accountSub, orgSub] = await Promise.all([ + const [rows, { isPro, activeSubscription }] = await Promise.all([ selectCreditsUsage({ account_id: accountId }), - getActiveSubscriptionDetails(accountId), - getOrgSubscription(accountId), + getAccountSubscriptionState(accountId), ]); - const hasAccountSub = isActiveSubscription(accountSub); - const hasOrgSub = isActiveSubscription(orgSub); - const isPro = hasAccountSub || hasOrgSub; - if (!rows || rows.length === 0) { return { creditsUsage: null, isPro }; } @@ -45,8 +38,8 @@ export async function checkAndResetCredits(accountId: string): Promise { + const [accountSub, orgSub] = await Promise.all([ + getActiveSubscriptionDetails(accountId), + getOrgSubscription(accountId), + ]); + const hasAccountSub = isActiveSubscription(accountSub); + const hasOrgSub = isActiveSubscription(orgSub); + return { + isPro: hasAccountSub || hasOrgSub, + activeSubscription: hasAccountSub ? accountSub : hasOrgSub ? orgSub : null, + }; +} diff --git a/lib/credits/initializeAccountCredits.ts b/lib/credits/initializeAccountCredits.ts new file mode 100644 index 000000000..30137f103 --- /dev/null +++ b/lib/credits/initializeAccountCredits.ts @@ -0,0 +1,19 @@ +import type { Tables } from "@/types/database.types"; +import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { getAccountSubscriptionState } from "@/lib/credits/getAccountSubscriptionState"; +import { DEFAULT_CREDITS, PRO_CREDITS } from "@/lib/credits/const"; + +/** + * Seeds a brand-new `credits_usage` row for an account with the plan-aware + * starting balance: `PRO_CREDITS` if the account (or an org they belong to) + * already has an active Stripe subscription, otherwise `DEFAULT_CREDITS`. + * + * Use this from any account-creation path. Do not call `insertCreditsUsage` + * directly with a hard-coded number — let this function pick the right value. + */ +export async function initializeAccountCredits( + accountId: string, +): Promise | null> { + const { isPro } = await getAccountSubscriptionState(accountId); + return insertCreditsUsage(accountId, isPro ? PRO_CREDITS : DEFAULT_CREDITS); +} diff --git a/lib/supabase/credits_usage/insertCreditsUsage.ts b/lib/supabase/credits_usage/insertCreditsUsage.ts index 78df4956c..a2876e914 100644 --- a/lib/supabase/credits_usage/insertCreditsUsage.ts +++ b/lib/supabase/credits_usage/insertCreditsUsage.ts @@ -1,20 +1,20 @@ import supabase from "../serverClient"; import type { Tables } from "@/types/database.types"; -/** Default credits for free tier accounts */ -const DEFAULT_CREDITS = 25; - /** - * Inserts a new credits_usage record for an account. - * Initializes with default credits. + * Inserts a new credits_usage record for an account at the supplied balance. + * + * This is the low-level DB op. Callers should not invoke it directly with a + * hard-coded number — go through `lib/credits/initializeAccountCredits` so the + * plan-aware DEFAULT_CREDITS / PRO_CREDITS choice stays in one place. * * @param accountId - The account ID to initialize credits for - * @param remainingCredits - Optional override for initial credits (defaults to 25) + * @param remainingCredits - Initial balance (caller decides — no default) * @returns The inserted credits_usage record, or null if failed */ export async function insertCreditsUsage( accountId: string, - remainingCredits: number = DEFAULT_CREDITS, + remainingCredits: number, ): Promise | null> { const { data, error } = await supabase .from("credits_usage")