Files
PaperClipAI/server/src/services/run-cost-accounting.test.ts
T
Nicky LeachandClaude Opus 5.5 aa8372fd5d fix(accounting): post late usage to its owning run (#18991)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native Runner sessions keep one provider conversation across task
turns, so Paperclip accounts usage per run.
> - Codex can report more usage for a turn after its run's charge was
posted.
> - The Runner then marked the session `reconciliation_required`. If
another run was attached, it also failed that run. Core refused the
delivery with `native_usage_after_finalization_requires_reconciliation`.
> - That usage has already happened. Blocking the session cannot undo
it, and the debt waited for an operator while the run totals stayed
wrong.
> - Codex totals are cumulative, and the Runner already knows the owning
run in most cases. So the correct amount can be posted automatically.
> - This pull request reports the exact increment with its owner, and
posts it to the owning run as a separate cost event.
> - The benefit is that run accounting becomes correct without stopping
work, and an old run's usage can no longer fail a newer run.

## Linked Issues or Issue Description

No issue exists. I searched open and closed pull requests and issues for
"late usage", `native_usage_after_finalization_requires_reconciliation`
and `codex_usage_accounting_integrity`. I found no duplicate.

**What happened?**

Code review found this while diagnosing session recovery failures. When
Codex reported usage above the known total for a turn that was not
active, the Runner blocked the session. If that turn belonged to a
previous run, the Runner also failed the attached run with
`codex_usage_accounting_integrity`. Core kept the receipt as debt, and
could correct it automatically only if the token counts were unchanged.

**Expected behavior**

Late usage does not stop any run or session. The charge goes to the run
that used the tokens, and it is auditable.

**Steps to reproduce**

1. Finish a Codex turn on a native Runner session and attach the next
run.
2. Have Codex send `thread/tokenUsage/updated` with a higher total for
the finished turn.
3. On the unfixed code, the attached run fails and later attaches are
refused.
`late_codex_usage_requires_durable_reconciliation_before_or_after_handoff`
encoded this behavior.

**Paperclip version or commit**

`master` at `04a24f4e06`.

**Deployment mode**

Any deployment that uses a native Runner with Codex.

## What Changed

- **Runner:** late usage no longer sets `reconciliation_required` or
fails a run. For a turn that belongs to a previous run, the Runner emits
`usage.reported` with the increment in `runDelta` and the owner in
`reconciliation.priorOrigin`. `runDeltaAvailable` stays `false`, so an
older Core does not bill it to the attached run.
- **`CodexRunUsage::absorb_late_usage`:** treats only usage above the
known thread total as late, and keeps the attached run's delta
unchanged. A report at or below that total adds nothing, so the sum
across runs always equals the thread total and a replay cannot charge
twice.
- **Delivery:** `captureLateUsageForOwner` stores the increment as a
late receipt on the owning run. The owner must be a finished run in the
same company and native session. If the Runner cannot name the owner,
Core uses the session's last finished run and records the attribution as
`inferred`.
- **Local Runner:** late usage stays with the run that is active when it
arrives, or the next run. Usage reported between two runs stays in the
next run's delta, so the total stays exact.
- **Accounting:** `reconcileLateRunUsageReceiptInTransaction` posts a
token increase as a new cost event with the key
`heartbeat:<run>:late:<receipt>`. It updates the run totals so the
accounting integrity check still agrees, records
`cost.late_usage_posted` in the activity log, and queues budget
evaluation.
- **No more refusal:** `persistUsageReceipt` no longer throws after it
saves late evidence, so late usage cannot wedge a Runner outbox.
- **Covered receipts:** an older same-source receipt at or below the
accepted totals, with matching billing, is cleared because a newer
cumulative receipt already covers it. Conflicting billing stays as debt.
- **Older Runners:** a zero-filled invalidation stays on the delivering
run.
- **Unchanged:** a newer lower total, a changed model or billing type,
and per-model receipts stay as visible accounting debt for an operator.
- **Unchanged:** an unexpected provider message during the warm
attachment drain still fails closed. A separate change addresses that
gate.
- **Docs:** added "Late provider usage" to `doc/execution-semantics.md`.

## Verification

- `cd packages/paperclip-runner/runner && cargo test --locked -p
paperclip-runner-core --test codex_provider`: 99 passed, 2 ignored.
- `cargo test --locked -p paperclip-runner-core --lib codex_usage`: 7
passed. This includes new tests for the out-of-order and repeated report
cases.
- `cd server && pnpm exec vitest run
src/services/run-cost-accounting.test.ts
src/services/native-runtime/native-usage-delivery.integration.test.ts
src/services/native-runtime/native-recovery-usage-delivery.test.ts
src/__tests__/cost-data-quality.test.ts`: passed against embedded
PostgreSQL before the last review fixes, and the accounting and delivery
files were re-run after them.
- `npx tsc --noEmit -p .` in `server` reports no errors.
- New tests cover exact and inferred owners, repeated delivery, late
usage that arrives before the owner's charge posts, another session's
run, an older Runner's report with no increment, a same-run increase,
and a lower total. They also run the accounting integrity check after
posting.
- **Review:** Greptile Apex scored 1/5, 4/5, 3/5, 2/5 and 4/5 while
findings were fixed, then 5/5 at the current head.
- **Local Runner:** driver tests cover usage reported between two runs,
including across a restore. No end-to-end local run has exercised it.
- **Not run locally:** the full test suite. CI provides that coverage.
- **Not verified live:** a read-only check of a test deployment found no
late receipts in its history, so this path has only run against
fixtures.

## Risks

- **Run totals can rise after a run finishes.** This is deliberate. The
correction is a separate cost event and an activity log entry.
- **Budgets are corrected after the fact.** A late charge can put an
agent over budget after later work has started. Budget evaluation runs
after each correction, but it cannot prevent that work.
- **Local sessions accept imperfect attribution by design.** Their late
usage can land on the active or next run, because local sessions have no
durable path for routing it. This also applies after a Stop or a pause.
The total stays exact.
- **Out-of-order reports can leave tokens on the wrong run in the
session.** If a prior turn's report arrives after the attached run has
already counted past that total, those tokens stay on the attached run.
Moving them would need a lower correction to the attached run. The total
stays exact.
- **Inferred attribution can name the wrong run in the session.** The
usage still belongs to that session and the total stays correct. The
attribution is recorded as `inferred`.
- **A session with no finished run charges the delivering run,** as an
inferred late charge after its own charge posts.
- **Rollout order:** deploy Core before the Runner. An older Core would
keep this event on the attached run as incomplete usage, and would still
refuse a same-run late receipt.
- **Migrations:** this change has no migration.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be reworked.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code,
with tool use and code execution. A human directed the design and
approved the scope.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:30:55 -08:00

506 lines
35 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, budgetEvaluationWork, budgetPolicies, companies, costEvents, costAdjustments, createDb, heartbeatRuns, projects, promptInjectionInvocations, runUsageReceipts, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { accountRunCost, createCostAccountingReconciler, readCostAccountingWork } from "./run-cost-accounting.js";
import { createRunUsageRecorder, persistUsageReceipt, type UsageReceiptEnvelope } from "./usage-receipts.js";
import * as usageReceipts from "./usage-receipts.js";
import { billingReconciliationService } from "./billing-reconciliation.js";
import { accountingIntegrityService } from "./accounting-integrity.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("run cost accounting work", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: Db;
let directory: string;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("run-cost-work-");
db = createDb(database.connectionString);
directory = await fs.mkdtemp(path.join(os.tmpdir(), "run-cost-work-"));
}, 60_000);
afterEach(async () => { await db.transaction(async tx => {
await tx.execute(sql`SET LOCAL client_min_messages = warning`);
await tx.execute(sql`TRUNCATE companies CASCADE`);
}); });
afterAll(async () => { if (directory) await fs.rm(directory, { force: true, recursive: true }); await database?.cleanup(); });
async function seed(change: Partial<typeof heartbeatRuns.$inferInsert> = {}) {
const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Accounting", issuePrefix: "ACCT" });
await db.insert(agents).values({ id: agentId, companyId, name: "Accounting", role: "engineer", status: "idle", adapterType: "process" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", status: "succeeded",
costAccountingPending: true, finishedAt: new Date(),
usageJson: { accountingReceiptReady: true, provider: "test", model: "test", billingType: "metered_api", costUsd: 0.01 }, ...change });
return { companyId, agentId, runId };
}
function receipt(owner: { companyId: string; runId: string }, sourceId: string, sequence: number, costUsd: number, inputTokens = 10): UsageReceiptEnvelope {
return { schema: "paperclip/accounting-receipt/v1", ...owner, id: randomUUID(), sourceId, sequence,
adapterType: "process", receivedAt: new Date().toISOString(),
receipt: { complete: true, usageBasis: "per_run", usage: { inputTokens, cachedInputTokens: 0, outputTokens: 2 },
provider: "test", biller: "test", model: "test", billingType: "metered_api", costUsd, costStatus: "reported",
pricingProvenance: { source: "provider_reported", version: "test" } } };
}
it("returns from settled or running calls without opening an accounting transaction", async () => {
const { runId } = await seed({ costAccountingPending: false });
const transaction = vi.fn(db.transaction.bind(db));
const observed = new Proxy(db, { get(target, property) {
if (property === "transaction") return transaction;
const value = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
} }) as Db;
expect(await accountRunCost(observed, runId)).toBe(false);
await db.update(heartbeatRuns).set({ status: "running", costAccountingPending: true }).where(eq(heartbeatRuns.id, runId));
expect(await accountRunCost(observed, runId)).toBe(false);
expect(transaction).not.toHaveBeenCalled();
});
it("settles once without delivering cancellation in the caller", async () => {
const { companyId, runId } = await seed();
const cancelWorkForScope = vi.fn(async () => { throw new Error("Cancellation must be asynchronous"); });
expect(await accountRunCost(db, runId, { cancelWorkForScope })).toBe(true);
expect(await accountRunCost(db, runId, { cancelWorkForScope })).toBe(false);
const events = await db.select().from(costEvents).where(and(eq(costEvents.companyId, companyId), eq(costEvents.heartbeatRunId, runId)));
expect(events).toHaveLength(1);
expect(events[0].costCents).toBe(1);
expect(cancelWorkForScope).not.toHaveBeenCalled();
expect(await readCostAccountingWork(db, Date.now(), directory)).toEqual({ pending: false, nextRunAt: null });
});
it("settles while company and agent accounting rows are held by unrelated writers", async () => {
const owner = await seed();
const [policy] = await db.insert(budgetPolicies).values({ companyId: owner.companyId, scopeType: "company",
scopeId: owner.companyId, windowKind: "calendar_month_utc", amount: 100 }).returning();
let release!: () => void, locked!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const acquired = new Promise<void>(resolve => { locked = resolve; });
const holder = db.transaction(async tx => {
await tx.select({ id: companies.id }).from(companies).where(eq(companies.id, owner.companyId)).for("no key update");
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, owner.agentId)).for("no key update");
await tx.select({ id: budgetPolicies.id }).from(budgetPolicies).where(eq(budgetPolicies.id, policy.id)).for("update");
locked();
await gate;
});
await acquired;
const settle = accountRunCost(db, owner.runId);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
expect(await Promise.race([settle, new Promise(resolve => { timer = setTimeout(() => resolve("blocked"), 1500); })])).toBe(true);
} finally { if (timer) clearTimeout(timer); release(); await holder; await settle; }
});
it("settles another run of the same company and agent while one run is locked", async () => {
const owner = await seed();
const [other] = await db.insert(heartbeatRuns).values({ companyId: owner.companyId, agentId: owner.agentId,
status: "succeeded", costAccountingPending: true, finishedAt: new Date(),
usageJson: { accountingReceiptReady: true, provider: "test", model: "test", billingType: "metered_api", costUsd: 0.02 } }).returning();
let release!: () => void, locked!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const acquired = new Promise<void>(resolve => { locked = resolve; });
const holder = db.transaction(async tx => {
await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)).for("update");
locked(); await gate;
});
await acquired;
const settle = accountRunCost(db, other.id);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
expect(await Promise.race([settle, new Promise(resolve => { timer = setTimeout(() => resolve("blocked"), 1500); })])).toBe(true);
} finally { if (timer) clearTimeout(timer); release(); await holder; await settle; }
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)))[0].costAccountedAt).toBeNull();
expect((await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, other.id)))[0].costCents).toBe(2);
});
it("rejects a changed final receipt identity after the spool pre-read and retries from the new closure", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 1,
accountingReceiptReady: true, costUsd: 0.01, provider: "test", model: "test", billingType: "metered_api" } });
let changed = false;
const observed = new Proxy(db, { get(target, property) {
if (property === "transaction") return async (...args: Parameters<Db["transaction"]>) => {
if (!changed) { changed = true; await persistUsageReceipt(db, receipt(owner, sourceId, 2, 0.02)); }
return target.transaction(...args);
};
const value = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
} }) as Db;
await expect(accountRunCost(observed, owner.runId)).rejects.toThrow("closure changed");
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId))).toEqual([]);
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect((await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId)))[0].costCents).toBe(2);
});
it("retains spool evidence imported only by a transaction that rolled back before a retry skipped settlement", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
const envelope = receipt(owner, sourceId, 1, 0.02);
const file = await usageReceipts.spoolUsageReceipt(envelope, directory);
const read = usageReceipts.readPendingRunUsageReceipts;
const spool = vi.spyOn(usageReceipts, "readPendingRunUsageReceipts").mockImplementation(input => read(input, directory));
let attempt = 0;
const observed = new Proxy(db, { get(target, property) {
if (property === "transaction") return async (callback: Parameters<Db["transaction"]>[0], options?: Parameters<Db["transaction"]>[1]) => {
attempt++;
if (attempt === 2) await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, owner.runId));
return target.transaction(async tx => {
const result = await callback(tx);
if (attempt === 1) throw Object.assign(new Error("forced serialization rollback"), { code: "40001" });
return result;
}, options);
};
const value = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
} }) as Db;
try {
expect(await accountRunCost(observed, owner.runId)).toBe(false);
expect(await fs.readFile(file, "utf8")).toContain(envelope.id);
expect(await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId))).toEqual([]);
expect(await db.select().from(runUsageReceipts).where(eq(runUsageReceipts.runId, owner.runId))).toEqual([]);
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, owner.runId));
expect(await accountRunCost(db, owner.runId)).toBe(true);
await expect(fs.stat(file)).rejects.toMatchObject({ code: "ENOENT" });
} finally { spool.mockRestore(); }
});
it("races receipt acceptance and finalization without dropping or duplicating authoritative spend", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
const results = await Promise.allSettled([accountRunCost(db, owner.runId), persistUsageReceipt(db, receipt(owner, sourceId, 2, 0.02))]);
for (const result of results) if (result.status === "rejected") expect(result.reason.message).toContain("closure changed");
await accountRunCost(db, owner.runId);
const events = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(events).toHaveLength(1);
expect(events[0].costCents).toBe(2);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)))[0].costAccountingPending).toBe(false);
});
it("commits conflicting native late evidence and keeps its debt quiet", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const late = { ...receipt(owner, sourceId, 2, 0.02, 5), producerEvent: { sourceEventId: "late", eventType: "usage.reported" } };
await persistUsageReceipt(db, late);
expect(await db.select().from(runUsageReceipts).where(eq(runUsageReceipts.id, late.id))).toHaveLength(1);
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(true);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(true);
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: true, accountingLateReceiptAttemptedId: late.id });
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(false);
expect((await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId)))[0].costCents).toBe(1);
});
it("never counts money twice when late growth and a price correction combine", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 1, 100));
await accountRunCost(db, owner.runId);
const total = async () => {
const charges = await db.select({ cents: sql<string>`${costEvents.costCents}::text` }).from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
return charges.reduce((sum, charge) => sum + Number(charge.cents), 0);
};
await persistUsageReceipt(db, receipt(owner, sourceId, 2, 1.2, 120));
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await total()).toBeCloseTo(120);
// The same tokens at a corrected whole-run price.
await persistUsageReceipt(db, receipt(owner, sourceId, 3, 1.3, 120));
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await total()).toBeCloseTo(130);
// More tokens after that correction charge only the new difference.
await persistUsageReceipt(db, receipt(owner, sourceId, 4, 1.5, 140));
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await total()).toBeCloseTo(150);
// Run totals follow provider-reported amounts, and corrections stay separate.
expect((await accountingIntegrityService(db).inspect(owner.companyId)).findings.filter(finding => finding.entityId === owner.runId)).toEqual([]);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)))[0].costAccountingPending).toBe(false);
});
it.each(["cumulative first", "delta first"])("adds a later run's increment to a queued source total: %s", async order => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 1, 100));
await accountRunCost(db, owner.runId);
const cumulative = receipt(owner, sourceId, 2, 1.2, 120);
const delta: UsageReceiptEnvelope = { ...receipt(owner, randomUUID(), 1, 0.1, 10), lateDelta: { attribution: "exact", reportedByRunId: randomUUID() } };
for (const item of order === "cumulative first" ? [cumulative, delta] : [delta, cumulative]) await persistUsageReceipt(db, item);
for (let attempt = 0; attempt < 3; attempt++) await accountRunCost(db, owner.runId);
const charges = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(charges.reduce((sum, charge) => sum + charge.inputTokens, 0)).toBe(130);
expect(charges.reduce((sum, charge) => sum + charge.costCents, 0)).toBeCloseTo(130);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.usageJson).toMatchObject({ inputTokens: 130, accountingLateReceiptPending: false });
expect((await accountingIntegrityService(db).inspect(owner.companyId)).findings.filter(finding => finding.entityId === owner.runId)).toEqual([]);
});
it("keeps late increment totals through a heartbeat metadata write", async () => {
const owner = await seed({ usageJson: { accountingReceiptOwner: "native_producer", accountingLateDeltas: { inputTokens: 10, units: "100000000" } } });
await db.update(heartbeatRuns).set({ usageJson: usageReceipts.preserveAcceptedRunUsageProjectionSql({ accountingLateDeltas: null, note: "completion" }) })
.where(eq(heartbeatRuns.id, owner.runId));
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.usageJson).toMatchObject({ note: "completion", accountingLateDeltas: { inputTokens: 10, units: "100000000" } });
});
it("clears an older growth receipt that a newer cumulative receipt already covers", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 1, 100));
await accountRunCost(db, owner.runId);
await persistUsageReceipt(db, receipt(owner, sourceId, 2, 1.2, 120));
await persistUsageReceipt(db, receipt(owner, sourceId, 3, 1.4, 140));
await accountRunCost(db, owner.runId);
// Clearing the remaining debt must wake budgets that the debt was holding.
await db.delete(budgetEvaluationWork).where(eq(budgetEvaluationWork.companyId, owner.companyId));
await accountRunCost(db, owner.runId);
expect((await db.select().from(budgetEvaluationWork).where(eq(budgetEvaluationWork.companyId, owner.companyId))).length).toBeGreaterThan(0);
const charges = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(charges.reduce((sum, charge) => sum + charge.inputTokens, 0)).toBe(140);
expect(charges.reduce((sum, charge) => sum + charge.costCents, 0)).toBeCloseTo(140);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: false });
expect(run.costAccountingPending).toBe(false);
expect(run.accountingLastError).toBeNull();
});
it("keeps an older receipt with conflicting billing as debt even when its tokens are covered", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 1, 100));
await accountRunCost(db, owner.runId);
const conflicting = receipt(owner, sourceId, 2, 1.2, 120);
conflicting.receipt.biller = "another-biller";
await persistUsageReceipt(db, conflicting);
await persistUsageReceipt(db, receipt(owner, sourceId, 3, 1.4, 140));
for (let attempt = 0; attempt < 3; attempt++) await accountRunCost(db, owner.runId);
const charges = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(charges.reduce((sum, charge) => sum + charge.inputTokens, 0)).toBe(140);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: true, accountingLateReceiptId: conflicting.id });
expect(run.accountingLastError).toBe("late_usage_requires_reconciliation");
});
it("corrects authoritative same-token late money on the original charge", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const [before] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
const late = receipt(owner, sourceId, 2, 0.025);
await persistUsageReceipt(db, late);
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const events = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ id: before.id, receiptHash: before.receiptHash, costCents: 2.5, reportedCostCents: "1.0000000" });
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, before.id))).toHaveLength(1);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)))[0].usageJson)
.toMatchObject({ accountingLateReceiptPending: false, accountingReconciledReceiptId: late.id });
});
it.each(["direct", "spool"])("does not revalue a corrected sequence3 from a late sequence2 via %s", async delivery => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const latest = receipt(owner, sourceId, 3, 0.03);
await persistUsageReceipt(db, latest);
expect(await accountRunCost(db, owner.runId)).toBe(true);
const older = receipt(owner, sourceId, 2, 0.02);
if (delivery === "spool") {
// Also exercise upgrade compatibility: the reconciled immutable receipt
// supplies the fence if explicit sequence metadata has not been written.
await db.update(heartbeatRuns).set({ usageJson: sql`${heartbeatRuns.usageJson}
- 'accountingReconciledReceiptSourceId' - 'accountingReconciledReceiptSequence'` }).where(eq(heartbeatRuns.id, owner.runId));
await usageReceipts.spoolUsageReceipt(older, directory);
expect(await usageReceipts.replayUsageReceipts(db, directory)).toEqual({ replayed: 1, failed: 0 });
} else await persistUsageReceipt(db, older);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(false);
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: false, accountingReconciledReceiptId: latest.id });
const [event] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(event.costCents).toBe(3);
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, event.id))).toHaveLength(1);
expect(await db.select().from(runUsageReceipts).where(eq(runUsageReceipts.runId, owner.runId))).toHaveLength(3);
});
it("resolves an already queued older money-only debt without rewinding a newer correction", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
await persistUsageReceipt(db, receipt(owner, sourceId, 2, 0.02));
const newest = receipt(owner, sourceId, 3, 0.03);
await persistUsageReceipt(db, newest);
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await accountRunCost(db, owner.runId)).toBe(true);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(false);
expect(run.usageJson).toMatchObject({ accountingReconciledReceiptId: newest.id,
accountingReconciledReceiptSequence: 3, accountingLateReceiptDebts: {} });
const [event] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(event.costCents).toBe(3);
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, event.id))).toHaveLength(1);
});
it("does not hide older ambiguous debt when a different late receipt is corrected", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const ambiguous = receipt(owner, randomUUID(), 1, 0.09, 90);
await persistUsageReceipt(db, ambiguous);
const canonical = receipt(owner, sourceId, 2, 0.02);
await persistUsageReceipt(db, canonical);
expect(await accountRunCost(db, owner.runId)).toBe(true);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(true);
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: true,
accountingLateReceiptDebts: { [ambiguous.id]: true }, accountingLateReceiptAttemptedId: ambiguous.id });
expect((run.usageJson!.accountingLateReceiptDebts as Record<string, boolean>)[canonical.id]).toBeUndefined();
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(false);
expect((await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId)))[0].costCents).toBe(2);
});
it.each(["billingType", "biller"])("keeps a late %s classification change as debt instead of a money-only correction", async field => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const late = receipt(owner, sourceId, 2, 0.02);
late.receipt = { ...late.receipt, ...(field === "billingType" ? { billingType: "subscription_included" } : { biller: "different-biller" }) };
await persistUsageReceipt(db, late);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const [event] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
expect(event).toMatchObject({ costCents: 1, billingType: "metered_api", biller: "test" });
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, event.id))).toEqual([]);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(true);
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: true, accountingLateReceiptDebts: { [late.id]: true } });
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(false);
});
it("retains reviewed operator valuation and resolves matching late debt through an explicit retry", async () => {
const sourceId = randomUUID();
const owner = await seed({ usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
await accountRunCost(db, owner.runId);
const [event] = await db.select().from(costEvents).where(eq(costEvents.heartbeatRunId, owner.runId));
await billingReconciliationService(db).adjust(owner.companyId, event.id, {
idempotencyKey: `operator:${owner.runId}`, expectedCents: "1", correctedCents: "4",
reason: "Reviewed authoritative invoice", pricing: { source: "operator", evidence: "Invoice review" },
}, "board");
const late = receipt(owner, sourceId, 2, 0.02);
await persistUsageReceipt(db, late);
expect(await accountRunCost(db, owner.runId)).toBe(false);
const [valued] = await db.select().from(costEvents).where(eq(costEvents.id, event.id));
expect(valued).toMatchObject({ costCents: 4, pricingProvenance: { source: "operator" } });
expect(await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, event.id))).toHaveLength(1);
expect(await db.select().from(runUsageReceipts).where(eq(runUsageReceipts.id, late.id))).toHaveLength(1);
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId)))[0].usageJson)
.toMatchObject({ accountingLateReceiptPending: true, accountingLateReceiptDebts: { [late.id]: true } });
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(false);
await billingReconciliationService(db).adjust(owner.companyId, event.id, {
idempotencyKey: `operator-reviewed-late:${owner.runId}`, expectedCents: "4", correctedCents: "2",
reason: "Reviewed the retained late provider receipt", pricing: { source: "operator", evidence: "Reviewed late receipt" },
}, "board");
expect(await accountingIntegrityService(db).retry(owner.companyId, owner.runId, "board")).toEqual({ accounted: true });
const [resolved] = await db.select().from(costEvents).where(eq(costEvents.id, event.id));
expect(resolved).toMatchObject({ costCents: 2, pricingProvenance: { source: "operator", evidence: "Reviewed late receipt" } });
const adjustments = await db.select().from(costAdjustments).where(eq(costAdjustments.costEventId, event.id));
expect(adjustments).toHaveLength(2);
expect(adjustments.every(adjustment => adjustment.actorId === "board")).toBe(true);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.costAccountingPending).toBe(false);
expect(run.usageJson).toMatchObject({ accountingLateReceiptPending: false, accountingLateReceiptDebts: {},
accountingReconciledReceiptId: late.id, accountingReconciledReceiptSequence: 2 });
});
it("fences a source handoff against a checkpoint committed during its spool pre-read", async () => {
const sourceId = randomUUID();
const owner = await seed({ status: "running", usageJson: { accountingReceiptSourceId: sourceId, accountingReceiptSequence: 0 } });
const [project] = await db.insert(projects).values({ companyId: owner.companyId, name: "Archived accounting scope" }).returning();
await db.update(heartbeatRuns).set({ usageJson: sql`${heartbeatRuns.usageJson} || ${JSON.stringify({ ledgerScope: { projectId: project.id } })}::jsonb` })
.where(eq(heartbeatRuns.id, owner.runId));
await persistUsageReceipt(db, receipt(owner, sourceId, 1, 0.01));
let changed = false;
const observed = new Proxy(db, { get(target, property) {
if (property === "transaction") return async (...args: Parameters<Db["transaction"]>) => {
if (!changed) { changed = true; await persistUsageReceipt(db, receipt(owner, sourceId, 2, 0.02)); }
return target.transaction(...args);
};
const value = Reflect.get(target, property);
return typeof value === "function" ? value.bind(target) : value;
} }) as Db;
await expect(createRunUsageRecorder(observed, { ...owner, adapterType: "process" }, directory)).rejects.toThrow("source changed");
await createRunUsageRecorder(db, { ...owner, adapterType: "process" }, directory);
await db.delete(budgetEvaluationWork).where(eq(budgetEvaluationWork.companyId, owner.companyId));
await persistUsageReceipt(db, receipt(owner, sourceId, 3, 0.03));
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, owner.runId));
expect(run.usageJson).toMatchObject({ accountingCaptureFailed: true, accountingReceiptReady: false,
accountingReceiptSourceFences: { [sourceId]: 2 } });
expect(run.accountingLastError).toBe("usage_after_source_handoff_requires_reconciliation");
expect(await db.select({ projectId: budgetEvaluationWork.projectId }).from(budgetEvaluationWork)
.where(eq(budgetEvaluationWork.companyId, owner.companyId))).toEqual([{ projectId: project.id }]);
});
it("repairs a stale pending marker without creating another charge", async () => {
const { runId } = await seed({ costAccountedAt: new Date(), usageJson: { accountingReceiptReady: false } });
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(true);
expect(await accountRunCost(db, runId)).toBe(false);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run.costAccountingPending).toBe(false);
expect(await db.select().from(costEvents)).toHaveLength(0);
});
it("waits for a missing final receipt without scheduling unchanged debt", async () => {
const { runId } = await seed({ usageJson: { accountingReceiptReady: false, inputTokens: 5 } });
expect(await readCostAccountingWork(db, Date.now(), directory)).toEqual({ pending: false, nextRunAt: null });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run.costAccountingPending).toBe(true);
await db.update(heartbeatRuns).set({ usageJson: { accountingReceiptReady: true, costUsd: 0.01 } }).where(eq(heartbeatRuns.id, runId));
expect((await readCostAccountingWork(db, Date.now(), directory)).pending).toBe(true);
});
it("reconstructs the actual interrupted-service deadline and disarms after settlement", async () => {
const { companyId } = await seed({ costAccountingPending: false });
const startedAt = new Date();
const [invocation] = await db.insert(promptInjectionInvocations).values({ companyId, source: "settings_test", actorType: "user", actorId: "test",
connectionId: randomUUID(), grantId: randomUUID(), policyVersion: "tool-result-v1", model: "openai/gpt-oss-safeguard-20b", startedAt }).returning();
const work = await readCostAccountingWork(db, startedAt.getTime(), directory);
expect(work).toEqual({ pending: false, nextRunAt: startedAt.getTime() + 120_001 });
expect((await readCostAccountingWork(db, startedAt.getTime() + 120_001, directory)).pending).toBe(true);
await db.update(promptInjectionInvocations).set({ status: "unknown" }).where(eq(promptInjectionInvocations.id, invocation.id));
expect(await readCostAccountingWork(db, startedAt.getTime() + 120_001, directory)).toEqual({ pending: false, nextRunAt: null });
});
it("shares service and run recovery across overlapping signals", async () => {
let release!: () => void;
const recover = vi.fn(() => new Promise<void>(resolve => { release = resolve; }));
const reconcile = createCostAccountingReconciler(db, {}, recover);
const first = reconcile(), second = reconcile();
expect(first).toBe(second);
expect(recover).toHaveBeenCalledOnce();
release();
await first;
expect(recover).toHaveBeenCalledOnce();
});
it("recovers run receipts even when an unrelated service invocation cannot settle", async () => {
const { runId } = await seed();
const reconcile = createCostAccountingReconciler(db, {}, async () => { throw new Error("Unrelated service receipt failed"); });
await expect(reconcile()).rejects.toThrow("Unrelated service receipt failed");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run.costAccountedAt).not.toBeNull();
expect(run.costAccountingPending).toBe(false);
expect(await db.select().from(costEvents)).toHaveLength(1);
});
});