From 37afa5431d7659167042463dde25c48366e209fa Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:44:29 +0000 Subject: [PATCH] fix(web): grant developer credit purchases once --- .../__tests__/unit/developer-actions.test.ts | 4 + .../unit/developer-credit-idempotency.test.ts | 150 ++++++++++++++++++ apps/web/lib/developer-credits.ts | 29 +++- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 apps/web/__tests__/unit/developer-credit-idempotency.test.ts diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index 4616b184235..9d35d669dd0 100644 --- a/apps/web/__tests__/unit/developer-actions.test.ts +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -599,6 +599,10 @@ describe("addCreditsToAccount", () => { txDb.limit = vi.fn(() => Promise.resolve([{ balanceMicroCredits: 500000 }]), ) as unknown as ReturnType; + txDb.limit.mockReturnValueOnce({ + for: vi.fn().mockResolvedValue([{ balanceMicroCredits: 0 }]), + }); + txDb.limit.mockResolvedValueOnce([]); return fn(txDb); }); diff --git a/apps/web/__tests__/unit/developer-credit-idempotency.test.ts b/apps/web/__tests__/unit/developer-credit-idempotency.test.ts new file mode 100644 index 00000000000..8f8fad99f0f --- /dev/null +++ b/apps/web/__tests__/unit/developer-credit-idempotency.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + balance: 0, + transactions: [] as Record[], + lock: Promise.resolve(), +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + transaction: async (run: (tx: unknown) => Promise) => { + let release: (() => void) | undefined; + async function lock() { + if (release) return; + const previous = state.lock; + state.lock = new Promise((resolve) => { + release = resolve; + }); + await previous; + } + const tx = { + select: () => ({ + from: (table: { balanceMicroCredits?: string }) => ({ + where: (conditions: { field: string; value: unknown }[]) => ({ + limit: () => { + const read = () => + table.balanceMicroCredits !== undefined + ? [{ balanceMicroCredits: state.balance }] + : state.transactions.filter((row) => + conditions.every( + ({ field, value }) => row[field] === value, + ), + ); + return Object.assign(Promise.resolve(read()), { + for: async () => { + if (table.balanceMicroCredits !== undefined) await lock(); + return read(); + }, + }); + }, + }), + }), + }), + update: () => ({ + set: (values: { balanceMicroCredits: number }) => ({ + where: async () => { + await lock(); + state.balance += values.balanceMicroCredits; + }, + }), + }), + insert: () => ({ + values: async (values: Record) => { + state.transactions.push(values); + }, + }), + }; + try { + return await run(tx); + } finally { + release?.(); + } + }, + }), +})); + +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "txn" })); +vi.mock("@cap/database/schema", () => ({ + developerCreditAccounts: { id: "id", balanceMicroCredits: "balance" }, + developerCreditTransactions: { + id: "id", + accountId: "accountId", + referenceId: "referenceId", + referenceType: "referenceType", + }, +})); +vi.mock("drizzle-orm", () => ({ + and: (...conditions: unknown[]) => conditions, + eq: (field: string, value: unknown) => ({ field, value }), + sql: (_strings: TemplateStringsArray, _column: unknown, amount: number) => + amount, +})); + +import { addCreditsToAccount } from "@/lib/developer-credits"; + +const purchase = { + accountId: "account-1", + amountCents: 2500, + referenceId: "pi_purchase", + referenceType: "stripe_payment_intent" as const, +}; + +describe("developer credit purchase idempotency", () => { + beforeEach(() => { + state.balance = 0; + state.transactions = []; + state.lock = Promise.resolve(); + }); + + it("grants a concurrently delivered purchase only once", async () => { + await Promise.all([ + addCreditsToAccount(purchase), + addCreditsToAccount(purchase), + ]); + expect(state.balance).toBe(2_500_000); + expect(state.transactions).toHaveLength(1); + }); + + it("returns the current balance when a purchase is retried", async () => { + await addCreditsToAccount(purchase); + state.balance -= 1000; + expect(await addCreditsToAccount(purchase)).toBe(2_499_000); + expect(state.transactions).toHaveLength(1); + }); + + it("grants distinct purchases on the same account", async () => { + await Promise.all([ + addCreditsToAccount(purchase), + addCreditsToAccount({ ...purchase, referenceId: "pi_other" }), + ]); + expect(state.balance).toBe(5_000_000); + expect(state.transactions).toHaveLength(2); + }); + + it.each([ + { accountId: "another-account", referenceType: "stripe_payment_intent" }, + { accountId: purchase.accountId, referenceType: "manual" }, + ])("does not match an unrelated transaction: %o", async (reference) => { + state.transactions.push({ + ...reference, + referenceId: purchase.referenceId, + }); + await addCreditsToAccount(purchase); + expect(state.balance).toBe(2_500_000); + expect(state.transactions).toHaveLength(2); + }); + + it("allows repeated top-ups without payment references", async () => { + await addCreditsToAccount({ + accountId: purchase.accountId, + amountCents: 500, + }); + await addCreditsToAccount({ + accountId: purchase.accountId, + amountCents: 500, + }); + expect(state.balance).toBe(1_000_000); + expect(state.transactions).toHaveLength(2); + }); +}); diff --git a/apps/web/lib/developer-credits.ts b/apps/web/lib/developer-credits.ts index da79b7464b1..826e32b56b0 100644 --- a/apps/web/lib/developer-credits.ts +++ b/apps/web/lib/developer-credits.ts @@ -5,7 +5,7 @@ import { developerCreditAccounts, developerCreditTransactions, } from "@cap/database/schema"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; const MICRO_CREDITS_PER_DOLLAR = 100_000; @@ -27,6 +27,33 @@ export async function addCreditsToAccount({ ); const newBalance = await db().transaction(async (tx) => { + if (referenceId && referenceType) { + const [account] = await tx + .select({ + balanceMicroCredits: developerCreditAccounts.balanceMicroCredits, + }) + .from(developerCreditAccounts) + .where(eq(developerCreditAccounts.id, accountId)) + .limit(1) + .for("update"); + + if (!account) throw new Error(`Credit account not found: ${accountId}`); + + const [existing] = await tx + .select({ id: developerCreditTransactions.id }) + .from(developerCreditTransactions) + .where( + and( + eq(developerCreditTransactions.accountId, accountId), + eq(developerCreditTransactions.referenceId, referenceId), + eq(developerCreditTransactions.referenceType, referenceType), + ), + ) + .limit(1); + + if (existing) return account.balanceMicroCredits; + } + await tx .update(developerCreditAccounts) .set({