From 99d9cdd7d7d8cd126cc5e9188ca943ea13e28eea Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 11 May 2026 16:16:10 -0500 Subject: [PATCH 1/2] fix(credits): seed new accounts at the right plan-aware balance (#549) 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") From 329c5891057e04ac43581f2c86d4e4312f214374 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 12 May 2026 04:35:50 +0530 Subject: [PATCH 2/2] feat(api): migrate POST /api/sandbox/upload to /api/sandboxes/staged-files (#541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): add sandbox staged-files token handshake endpoint Adds POST /api/sandboxes/staged-files — the Vercel Blob client-upload handshake half of the sandbox upload flow. Mirrors chat's existing /api/sandbox/upload handler so the chat side can flip its handleUploadUrl to api and delete the local route. Auth follows the minimum-port shape: validates clientPayload.token because @vercel/blob/client.upload() does not allow setting an Authorization header on the handshake POST. The downstream commit (POST /api/sandboxes/files) re-authenticates with a real Bearer token. OPTIONS preflight is wired with getCorsHeaders() so cross-origin calls from chat.recoupable.com work the same way the existing /api/sandboxes/files route does. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): rename /api/sandboxes/staged-files to /api/sandboxes/stage-files Verb form matches /api/sandboxes/files (commit) and /api/sandboxes/file (get). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): rename /api/sandboxes/stage-files to /api/sandboxes/staged-file Noun-shaped resource matching /api/sandboxes/file (singular getter) and /api/sandboxes/files (collection commit). Each call generates a token to stage one file. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): authenticate staged-file via Authorization header Drops clientPayload.token in favor of validateAuthContext() so the endpoint matches the auth surface of every other api route. The chat-side upload() now forwards an Authorization Bearer header via the library's headers option (which I missed earlier — that field is documented for exactly this use case). Branches on body.type to skip auth on the upload-completed callback; that POST comes from Vercel Blob's backend without the user's auth header, and handleUpload() verifies its signature internally. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): hide raw exception details on staged-file 500s Log unexpected handleUpload errors server-side via console.error and return a generic 500 — matches the createSandboxHandler pattern and avoids leaking internal context in error responses. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): trim JSDoc on staged-file handler and route Drop the multi-paragraph commentary; keep a one-liner on the handler explaining the asymmetric auth, and minimal JSDoc on the route per project convention. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(api): satisfy jsdoc/require-returns + require-param on staged-file route Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/sandboxes/staged-file/route.ts | 23 ++++ .../postSandboxesUploadTokensHandler.test.ts | 124 ++++++++++++++++++ .../postSandboxesUploadTokensHandler.ts | 41 ++++++ 3 files changed, 188 insertions(+) create mode 100644 app/api/sandboxes/staged-file/route.ts create mode 100644 lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts create mode 100644 lib/sandbox/postSandboxesUploadTokensHandler.ts diff --git a/app/api/sandboxes/staged-file/route.ts b/app/api/sandboxes/staged-file/route.ts new file mode 100644 index 000000000..408df9640 --- /dev/null +++ b/app/api/sandboxes/staged-file/route.ts @@ -0,0 +1,23 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postSandboxesUploadTokensHandler } from "@/lib/sandbox/postSandboxesUploadTokensHandler"; + +/** + * CORS preflight. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * POST /api/sandboxes/staged-file — Vercel Blob client-upload token handshake. + * + * @param request - The request object. + * @returns A NextResponse with the handshake result or error. + */ +export async function POST(request: NextRequest): Promise { + return postSandboxesUploadTokensHandler(request); +} diff --git a/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts new file mode 100644 index 000000000..fbba4a814 --- /dev/null +++ b/lib/sandbox/__tests__/postSandboxesUploadTokensHandler.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { handleUpload } from "@vercel/blob/client"; + +import { postSandboxesUploadTokensHandler } from "../postSandboxesUploadTokensHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@vercel/blob/client", () => ({ + handleUpload: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +function createMockRequest(body: unknown, headers: Record = {}): NextRequest { + return new Request("http://localhost:3000/api/sandboxes/staged-file", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }) as unknown as NextRequest; +} + +const handshakeBody = { type: "blob.generate-client-token", payload: {} }; +const callbackBody = { type: "blob.upload-completed", payload: {} }; + +describe("postSandboxesUploadTokensHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "tkn", + }); + }); + + it("returns 200 with the handleUpload result on a valid handshake", async () => { + const blobResponse = { type: "blob.generate-client-token", clientToken: "tkn_abc" }; + vi.mocked(handleUpload).mockResolvedValue(blobResponse as never); + + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(blobResponse); + expect(validateAuthContext).toHaveBeenCalledOnce(); + expect(handleUpload).toHaveBeenCalledOnce(); + }); + + it("returns 401 when handshake auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(handshakeBody); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(401); + expect(handleUpload).not.toHaveBeenCalled(); + }); + + it("skips auth on the upload-completed callback", async () => { + vi.mocked(handleUpload).mockResolvedValue({ type: "blob.upload-completed" } as never); + + const request = createMockRequest(callbackBody); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(200); + expect(validateAuthContext).not.toHaveBeenCalled(); + expect(handleUpload).toHaveBeenCalledOnce(); + }); + + it("configures the upload constraints in onBeforeGenerateToken", async () => { + let constraints: unknown; + vi.mocked(handleUpload).mockImplementation(async ({ onBeforeGenerateToken }) => { + constraints = await onBeforeGenerateToken!("file.png", null, false); + return { type: "blob.generate-client-token" } as never; + }); + + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(200); + expect(constraints).toEqual({ + maximumSizeInBytes: 100 * 1024 * 1024, + addRandomSuffix: true, + }); + }); + + it("returns 500 with a generic message when handleUpload throws", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(handleUpload).mockRejectedValue(new Error("blob client failure")); + + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body).toEqual({ status: "error", error: "Failed to issue upload token" }); + expect(consoleSpy).toHaveBeenCalledOnce(); + consoleSpy.mockRestore(); + }); + + it("includes CORS headers on success", async () => { + vi.mocked(handleUpload).mockResolvedValue({ type: "blob.generate-client-token" } as never); + + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers on error", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(handleUpload).mockRejectedValue(new Error("nope")); + + const request = createMockRequest(handshakeBody, { Authorization: "Bearer xyz" }); + const response = await postSandboxesUploadTokensHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + consoleSpy.mockRestore(); + }); +}); diff --git a/lib/sandbox/postSandboxesUploadTokensHandler.ts b/lib/sandbox/postSandboxesUploadTokensHandler.ts new file mode 100644 index 000000000..044b52735 --- /dev/null +++ b/lib/sandbox/postSandboxesUploadTokensHandler.ts @@ -0,0 +1,41 @@ +import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + +// Auth applies only to the handshake — the upload-completed callback is signature-verified by handleUpload(). +export async function postSandboxesUploadTokensHandler( + request: NextRequest, +): Promise { + try { + const body = (await request.json()) as HandleUploadBody; + + if (body.type === "blob.generate-client-token") { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + } + + const jsonResponse = await handleUpload({ + body, + request, + onBeforeGenerateToken: async () => ({ + maximumSizeInBytes: MAX_UPLOAD_SIZE_BYTES, + addRandomSuffix: true, + }), + onUploadCompleted: async () => {}, + }); + + return NextResponse.json(jsonResponse, { headers: getCorsHeaders() }); + } catch (error) { + console.error("[postSandboxesUploadTokensHandler] handleUpload failed:", error); + return NextResponse.json( + { status: "error", error: "Failed to issue upload token" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +}