import { appendFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UsageLedger, type BeginUsage } from "../src/billing/ledger.js"; import { silentLogger, tempRoot } from "./helpers.js"; function usage(overrides: Partial = {}): BeginUsage { return { uid: "1", team_id: "1", session_id: "s1", tool_call_id: "call-1", skill_id: "demo", skill_name: "Demo", model: "seedream@5.0", size: "1024x1536", quantity: 2, unit_credits: 30, size_multiplier: 1, total_credits: 60, currency: "credits", billing_mode: "dry_run", ...overrides, }; } describe("UsageLedger", () => { it("records pending and succeeded usage and finds it by tool call", () => { const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger); const record = ledger.begin(usage()); expect(record.status).toBe("pending"); ledger.settle(record.id, "succeeded", { gateway_task_id: "task-1", image_urls: ["https://img/1.png", "https://img/2.png"], }); const found = ledger.findSucceededToolCall("1", "s1", "call-1"); expect(found?.id).toBe(record.id); expect(found?.status).toBe("succeeded"); expect(found?.image_urls).toHaveLength(2); expect(ledger.findSucceededToolCall("2", "s1", "call-1")).toBeUndefined(); }); it("persists records and rebuilds the idempotency index", () => { const file = join(tempRoot(), "ledger.jsonl"); const ledger = new UsageLedger(file, silentLogger); const record = ledger.begin(usage()); ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] }); const reloaded = new UsageLedger(file, silentLogger); expect(reloaded.list()).toHaveLength(1); expect(reloaded.findSucceededToolCall("1", "s1", "call-1")?.id).toBe(record.id); }); it("does not index failed calls and summarises by status and skill", () => { const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger); const failed = ledger.begin(usage({ tool_call_id: "call-failed" })); ledger.settle(failed.id, "failed", { error: "nope" }); const ok = ledger.begin(usage({ tool_call_id: "call-ok", skill_id: "poster", total_credits: 30, quantity: 1 })); ledger.settle(ok.id, "succeeded", { image_urls: ["https://img/p.png"] }); const pending = ledger.begin(usage({ tool_call_id: "call-pending", skill_id: "poster", total_credits: 30 })); expect(ledger.findSucceededToolCall("1", "s1", "call-failed")).toBeUndefined(); const summary = ledger.summary({ uid: "1" }); expect(summary.totals).toEqual({ records: 3, images: 1, credits_succeeded: 30, credits_pending: 30, credits_failed: 60, }); expect(summary.by_skill).toEqual([ { skill_id: "poster", skill_name: "Demo", records: 2, images: 1, credits: 30 }, { skill_id: "demo", skill_name: "Demo", records: 1, images: 0, credits: 0 }, ]); expect(ledger.list({ status: "pending" }).map((item) => item.id)).toEqual([pending.id]); expect(ledger.list({ limit: 1 })).toHaveLength(1); }); it("skips malformed lines instead of losing the ledger", () => { const file = join(tempRoot(), "ledger.jsonl"); const ledger = new UsageLedger(file, silentLogger); ledger.begin(usage()); appendFileSync(file, "{bad json\n", "utf8"); const reloaded = new UsageLedger(file, silentLogger); expect(reloaded.list()).toHaveLength(1); }); });