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.

P1: Handle initializeAccountCredits returning null; otherwise this path can return 200 for an account that was created without its credits row.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/accounts/createAccountHandler.ts, line 94:

<comment>Handle `initializeAccountCredits` returning `null`; otherwise this path can return 200 for an account that was created without its credits row.</comment>

<file context>
@@ -91,7 +91,7 @@ export async function createAccountHandler(body: CreateAccountBody): Promise<Nex
     }
 
-    await insertCreditsUsage(newAccount.id);
+    await initializeAccountCredits(newAccount.id);
 
     const newAccountData: AccountDataResponse = {
</file context>
Suggested change
await initializeAccountCredits(newAccount.id);
const credits = await initializeAccountCredits(newAccount.id);
if (!credits) {
throw new Error("createAccountHandler: initializeAccountCredits returned null");
}


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