Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions lib/accounts/__tests__/createAccountHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/accounts/createAccountHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -91,7 +91,7 @@ export async function createAccountHandler(body: CreateAccountBody): Promise<Nex
await insertAccountWallet(newAccount.id, wallet);
}

await insertCreditsUsage(newAccount.id);
await initializeAccountCredits(newAccount.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling for credit initialization.

Unlike createAccountWithEmail (which throws if initializeAccountCredits returns null), this handler silently proceeds when credit initialization fails. If initializeAccountCredits returns null, the account will exist without a credits_usage row, causing downstream read failures.

🛡️ Recommended fix
- await initializeAccountCredits(newAccount.id);
+ const credits = await initializeAccountCredits(newAccount.id);
+ if (!credits) {
+   throw new Error("Failed to initialize account credits");
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await initializeAccountCredits(newAccount.id);
const credits = await initializeAccountCredits(newAccount.id);
if (!credits) {
throw new Error("Failed to initialize account credits");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/accounts/createAccountHandler.ts` at line 94, The call to
initializeAccountCredits in createAccountHandler currently ignores failures and
can leave an account without a credits_usage row; change the call to capture the
return value (e.g., const credits = await
initializeAccountCredits(newAccount.id)) and if it is null/falsey throw an error
(or otherwise abort the handler) similar to createAccountWithEmail’s behavior so
the account creation does not silently proceed without initialized credits;
reference initializeAccountCredits and createAccountHandler (and mirror the
error handling used in createAccountWithEmail) to locate and implement the fix.


const newAccountData: AccountDataResponse = {
id: newAccount.id,
Expand Down
4 changes: 2 additions & 2 deletions lib/agents/__tests__/agentSignupHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,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" })),
}));

vi.mock("@/lib/keys/generateApiKey", () => ({
Expand Down
22 changes: 12 additions & 10 deletions lib/agents/__tests__/createAccountWithEmail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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", () => {
Expand All @@ -25,9 +25,9 @@ describe("createAccountWithEmail", () => {
vi.mocked(insertAccountEmail).mockResolvedValue({
id: "ae_1",
} as unknown as Awaited<ReturnType<typeof insertAccountEmail>>);
vi.mocked(insertCreditsUsage).mockResolvedValue({
vi.mocked(initializeAccountCredits).mockResolvedValue({
id: "cu_1",
} as unknown as Awaited<ReturnType<typeof insertCreditsUsage>>);
} as unknown as Awaited<ReturnType<typeof initializeAccountCredits>>);
});

it("creates the account, inserts the email link and credits row, and returns the new account id", async () => {
Expand All @@ -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 () => {
Expand All @@ -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<ReturnType<typeof insertCreditsUsage>>,
it("throws when initializeAccountCredits returns null", async () => {
vi.mocked(initializeAccountCredits).mockResolvedValueOnce(
null as unknown as Awaited<ReturnType<typeof initializeAccountCredits>>,
);

await expect(createAccountWithEmail("user@example.com")).rejects.toThrow(/insertCreditsUsage/);
await expect(createAccountWithEmail("user@example.com")).rejects.toThrow(
/initializeAccountCredits/,
);
});
});
6 changes: 3 additions & 3 deletions lib/agents/createAccountWithEmail.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,9 +27,9 @@ export async function createAccountWithEmail(email: string): Promise<string> {
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;
Expand Down
67 changes: 30 additions & 37 deletions lib/credits/__tests__/checkAndResetCredits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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 }> = {},
) => ({
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down
72 changes: 72 additions & 0 deletions lib/credits/__tests__/getAccountSubscriptionState.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading