Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/web/__tests__/unit/developer-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,10 @@ describe("addCreditsToAccount", () => {
txDb.limit = vi.fn(() =>
Promise.resolve([{ balanceMicroCredits: 500000 }]),
) as unknown as ReturnType<typeof vi.fn>;
txDb.limit.mockReturnValueOnce({
for: vi.fn().mockResolvedValue([{ balanceMicroCredits: 0 }]),
});
txDb.limit.mockResolvedValueOnce([]);
return fn(txDb);
});

Expand Down
150 changes: 150 additions & 0 deletions apps/web/__tests__/unit/developer-credit-idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const state = vi.hoisted(() => ({
balance: 0,
transactions: [] as Record<string, unknown>[],
lock: Promise.resolve(),
}));

vi.mock("@cap/database", () => ({
db: () => ({
transaction: async (run: (tx: unknown) => Promise<unknown>) => {
let release: (() => void) | undefined;
async function lock() {
if (release) return;
const previous = state.lock;
state.lock = new Promise<void>((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<string, unknown>) => {
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);
});
});
29 changes: 28 additions & 1 deletion apps/web/lib/developer-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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({
Expand Down
Loading