import { beforeEach, describe, expect, mock, test } from "bun:test"; import { fakeD1 } from "./fake-d1.test-helper"; import { memoryScopes } from "../scoped-db/testing"; let d1 = fakeD1(); const kv = new Map(); const HOUR = 3600; const fakeEnv: Record = { get DB() { return d1.binding; }, KV: { get: async (key: string) => kv.get(key) ?? null, put: async (key: string, value: string) => void kv.set(key, value), delete: async (key: string) => void kv.delete(key), }, METERING_PLANS: "", PUBLIC_URL: "https://forge.test", }; mock.module("cloudflare:workers", () => ({ env: fakeEnv, waitUntil: () => undefined })); let scopes = memoryScopes(); mock.module("../scoped-db", () => ({ repoDb: (id: string) => scopes.repoDb(id), accountDb: (id: string) => scopes.accountDb(id), stmt: scopes.module.stmt, })); const accountSql = (id: string) => scopes.raw("account", id); const { recordUsage, periodOf } = await import("./record"); const { reserveUsage, commitUsage, releaseUsage, remainingQuota, assertWithinQuota, setPlan, setSpendingLimit, periodSpendCents, billingSettings, QuotaExceededError, } = await import("./quota"); const { applyCreditMovement, creditBalance, isCreditMeter } = await import("./credits"); const { usageSummary } = await import("./summary"); const { METER_PRICES, creditPoolFor } = await import("~/lib/pricing"); const { aiMeter } = await import("./ai"); function period(accountId: string, meter: string, at = periodOf()) { return accountSql(accountId) .query( "SELECT quantity, reserved, credit_covered FROM usage_periods WHERE account_id = ? AND meter = ? AND period = ?", ) .get(accountId, meter, at) as { quantity: number; reserved: number; credit_covered: number } | null; } async function buy(accountId: string, meter: "compute" | "ai.tokens", quantity: number, ref = `p-${quantity}`) { return applyCreditMovement({ ref, accountId, meter, kind: "purchase", quantity, paymentIntent: `pi_${ref}` }); } beforeEach(() => { d1 = fakeD1(); scopes = memoryScopes(); kv.clear(); d1.db.exec("INSERT INTO accounts (id, name, kind) VALUES ('u1', 'alice', 'user'), ('o1', 'acme', 'org');"); }); describe("credit ledger", () => { test("only priced compute meters and ai.tokens hold credit", () => { expect(isCreditMeter("compute.runner")).toBe(true); expect(isCreditMeter("compute.editor")).toBe(true); expect(isCreditMeter("ai.tokens")).toBe(true); expect(isCreditMeter("compute.runner_self_hosted")).toBe(false); expect(isCreditMeter("ai.calls")).toBe(false); expect(isCreditMeter("storage.lfs")).toBe(false); }); test("purchases are idempotent by ref", async () => { expect(await buy("u1", "compute", 600)).toBe(600); expect(await buy("u1", "compute", 600)).toBe(0); expect(await creditBalance("u1", "compute")).toBe(600); }); test("draw-down order: included first, then credit, then the hard cap (free)", async () => { await buy("u1", "compute", 1000); // Free includes 5 hours; usage inside the allowance never touches credit. await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR - 100, idempotencyKey: "a" }); expect(await creditBalance("u1", "compute")).toBe(1000); // 300 more: 100 from included, 200 from credit. await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 300, idempotencyKey: "b" }); expect(await creditBalance("u1", "compute")).toBe(800); expect(period("u1", "compute.runner")?.credit_covered).toBe(200); // Headroom left is exactly the unspent credit. expect(await remainingQuota("u1", "compute.runner")).toBe(800); await expect(assertWithinQuota("u1", "compute.runner", 801)).rejects.toBeInstanceOf(QuotaExceededError); await assertWithinQuota("u1", "compute.runner", 800); }); test("one compute pool pays for every runner size at its relative price", async () => { const factor = creditPoolFor("compute.runner_xlarge")?.factor ?? 0; expect(factor).toBeGreaterThan(5); await buy("u1", "compute", 1000); // Free includes no standard-4 time, so all of it draws credit at the standard-4 rate. await recordUsage({ accountId: "u1", meter: "compute.runner_xlarge", quantity: 100, idempotencyKey: "x" }); expect(await creditBalance("u1", "compute")).toBeCloseTo(1000 - 100 * factor, 6); expect(await remainingQuota("u1", "compute.runner_xlarge")).toBeCloseTo((1000 - 100 * factor) / factor, 6); }); test("holds on different runner sizes can't both claim the same credit", async () => { await setPlan("u1", "free", "billing"); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR, idempotencyKey: "fill" }); await buy("u1", "compute", 1000); const factor = creditPoolFor("compute.runner_xlarge")?.factor ?? 0; // Free has no standard-4 allowance and caps it at 0, so its whole hold must come from credit. await reserveUsage({ id: "small", accountId: "u1", meter: "compute.runner", quantity: 900 }); await expect( reserveUsage({ id: "big", accountId: "u1", meter: "compute.runner_xlarge", quantity: 200 / factor }), ).rejects.toBeInstanceOf(QuotaExceededError); await reserveUsage({ id: "fits", accountId: "u1", meter: "compute.runner_xlarge", quantity: 90 / factor }); }); test("an expired hold on another size never blocks credit", async () => { await setPlan("u1", "free", "billing"); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR, idempotencyKey: "fill" }); await buy("u1", "compute", 1000); await reserveUsage({ id: "stale", accountId: "u1", meter: "compute.runner", quantity: 900, ttlMs: -1 }); const factor = creditPoolFor("compute.runner_xlarge")?.factor ?? 0; await reserveUsage({ id: "fresh", accountId: "u1", meter: "compute.runner_xlarge", quantity: 900 / factor }); }); test("retried events never draw twice", async () => { await buy("u1", "ai.tokens", 50_000); const event = { accountId: "u1", meter: "ai.tokens", quantity: 210_000, idempotencyKey: "t1" } as const; await recordUsage(event); await recordUsage(event); expect(await creditBalance("u1", "ai.tokens")).toBe(40_000); }); test("a retry after a purchase never draws for usage already settled", async () => { const event = { accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR + 500, idempotencyKey: "early", } as const; await recordUsage(event); await buy("u1", "compute", 1000); await recordUsage(event); expect(await creditBalance("u1", "compute")).toBe(1000); }); test("reservations count credit as headroom and settle draws on commit", async () => { await setPlan("u1", "free", "billing"); await buy("u1", "ai.tokens", 1000); await recordUsage({ accountId: "u1", meter: "ai.tokens", quantity: 200_000, idempotencyKey: "fill" }); const hold = await reserveUsage({ id: "r1", accountId: "u1", meter: "ai.tokens", quantity: 600 }); await expect(reserveUsage({ id: "r2", accountId: "u1", meter: "ai.tokens", quantity: 500 })).rejects.toBeInstanceOf( QuotaExceededError, ); await commitUsage(hold, { quantity: 400 }); expect(await creditBalance("u1", "ai.tokens")).toBe(600); const second = await reserveUsage({ id: "r3", accountId: "u1", meter: "ai.tokens", quantity: 600 }); await releaseUsage(second.id, "u1"); expect(await creditBalance("u1", "ai.tokens")).toBe(600); }); test("paid plans spend credit before overage and the spending limit", async () => { await setPlan("u1", "pro", "billing"); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 35 * HOUR, idempotencyKey: "fill" }); await buy("u1", "compute", 600); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 1000, idempotencyKey: "over" }); expect(await creditBalance("u1", "compute")).toBe(0); const settings = await billingSettings("u1"); const cents = await periodSpendCents("u1", settings); expect(cents).toBeCloseTo((400 * METER_PRICES["compute.runner"]) / 1e6, 6); const summary = await usageSummary("u1"); const runner = summary.meters.find((meter) => meter.meter === "compute.runner"); expect(runner?.creditCovered).toBe(600); expect(runner?.overageCents).toBeCloseTo(cents, 6); await setSpendingLimit("u1", 0); expect(await remainingQuota("u1", "compute.runner")).toBe(0); }); test("credit survives the period rollover", async () => { await buy("u1", "compute", 1000); const lastMonth = Date.UTC(2026, 0, 15); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR + 400, idempotencyKey: "jan", at: lastMonth, }); expect(await creditBalance("u1", "compute")).toBe(600); const nextMonth = Date.UTC(2026, 1, 15); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR + 100, idempotencyKey: "feb", at: nextMonth, }); expect(await creditBalance("u1", "compute")).toBe(500); expect(period("u1", "compute.runner", "2026-02")?.credit_covered).toBe(100); }); test("refunds and disputes reverse only unused credit", async () => { await buy("u1", "compute", 1000, "cs1"); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: 5 * HOUR + 700, idempotencyKey: "use" }); expect(await creditBalance("u1", "compute")).toBe(300); const removed = await applyCreditMovement({ ref: "refund:1", accountId: "u1", meter: "compute", kind: "refund", quantity: 1000, }); expect(removed).toBe(-300); expect(await creditBalance("u1", "compute")).toBe(0); }); test("BYOK tokens never draw credit or plan AI quota", async () => { await buy("u1", "ai.tokens", 1000); const meter = aiMeter({ accountId: "u1", feature: "test", scope: "s1" }); const index = await meter.beforeCall("m", true); await meter.afterCall(index, "m", { inputTokens: 300_000, outputTokens: 10 }, true); expect(period("u1", "ai.tokens")).toBeNull(); expect(period("u1", "ai.calls")).toBeNull(); expect(period("u1", "ai.byok_tokens")?.quantity).toBe(300_010); expect(period("u1", "ai.byok_calls")?.quantity).toBe(1); expect(await creditBalance("u1", "ai.tokens")).toBe(1000); }); test("prepaid credit still pays for holds under a tight spending limit (BILL-P1)", async () => { await setPlan("u1", "pro", "billing"); await setSpendingLimit("u1", 1); const included = (await billingSettings("u1")).plan.included["compute.runner"] ?? 0; const factor = creditPoolFor("compute.runner")?.factor ?? 1; await buy("u1", "compute", 600 * factor); const hold = await reserveUsage({ id: "credit", accountId: "u1", meter: "compute.runner", quantity: included + 600, }); expect(hold.quantity).toBe(included + 600); const twoCents = (2 * 1e6) / METER_PRICES["compute.runner"]; const over = await reserveUsage({ id: "over", accountId: "u1", meter: "compute.runner", quantity: twoCents }).catch( (error: unknown) => error, ); expect(over).toBeInstanceOf(QuotaExceededError); }); });