feat: add accounting CLI workflows and dashboard previews

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-06 22:27:01 -07:00
1 parent 799e4d556f
commit c1102f05d4
21 files changed
+1365 -13

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerCostCommands } from "../commands/client/cost.js";
function program() {
const result = new Command().exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerCostCommands(result);
return result;
}
const context = ["--company-id", "test-company", "--api-base", "http://paperclip.test", "--api-key", "test-key", "--json"];
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
describe("cost report date options", () => {
it.each([["cost", "summary", "costs/summary"], ["finance", "events", "costs/finance-events"]])("forwards bounds and all-time for %s %s", async (group, name, path) => {
const fetch = vi.fn().mockImplementation(() => Promise.resolve(Response.json({})));
vi.stubGlobal("fetch", fetch);
vi.spyOn(console, "log").mockImplementation(() => {});
await program().parseAsync([group, name, ...context, "--all-time"], { from: "user" });
expect(fetch.mock.calls[0][0]).toBe(`http://paperclip.test/api/companies/test-company/${path}?period=all`);
await program().parseAsync([group, name, ...context, "--from", "2026-09-01", "--to", "2026-09-02"], { from: "user" });
expect(fetch.mock.calls[1][0]).toBe(`http://paperclip.test/api/companies/test-company/${path}?from=2026-09-01&to=2026-09-02`);
});
it.each([["accounting", "health"], ["accounting", "inspect"], ["accounting", "invoices"], ["budget", "overview"], ["cost", "window-spend"], ["cost", "quota-windows"]])("rejects unsupported date flags for %s %s", async (group, name) => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
for (const args of [["--all-time"], ["--from", "2026-09-01"], ["--to", "2026-09-02"]]) {
await expect(program().parseAsync([group, name, ...context, ...args], { from: "user" })).rejects.toThrow("unknown option");
}
expect(fetch).not.toHaveBeenCalled();
});
});
describe("accounting operator requests", () => {
const id = "5bc31923-fd9c-4bc5-a717-c975eebc4331";
const requests = [
{ name: "repair", path: "accounting/repair", payload: { fingerprint: "a".repeat(64), reason: "Reviewed projection drift" } },
{ name: "retry", path: "accounting/retry", payload: { runId: id } },
{ name: "provider:import", path: "accounting/provider-costs/import", payload: { provider: "openai", secretId: id, accountId: "account-1", scopeIds: ["project-1"], from: "2026-09-01", to: "2026-09-03" } },
{ name: "invoice:import", path: "accounting/invoices", payload: { biller: "openai", externalId: "invoice-1", currency: "USD", lines: [{ externalId: "line-1", amountCents: "0.0000001", occurredAt: "2026-09-01T00:00:00Z", runId: id }] } },
{ name: "event:correct", path: `accounting/events/${id}/adjustments`, id, payload: { idempotencyKey: "correction-1", expectedCents: "0.0000001", correctedCents: "1.2500001", reason: "Provider invoice", pricing: { source: "provider_invoice" } } },
];
it.each(requests)("posts the company-scoped $name request without changing exact amounts", async ({ name, path, payload, ...extra }) => {
const fetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal("fetch", fetch);
vi.spyOn(console, "log").mockImplementation(() => {});
await program().parseAsync(["accounting", name, ...(extra.id ? [extra.id] : []), ...context, "--payload-json", JSON.stringify(payload)], { from: "user" });
expect(fetch).toHaveBeenCalledOnce();
const [url, options] = fetch.mock.calls[0];
expect(url).toBe(`http://paperclip.test/api/companies/test-company/${path}`);
expect(options.method).toBe("POST");
expect(JSON.parse(options.body)).toEqual(payload);
});
it("reviews one invoice using GET with an encoded ID and no request body", async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({ id }));
vi.stubGlobal("fetch", fetch);
vi.spyOn(console, "log").mockImplementation(() => {});
await program().parseAsync(["accounting", "invoice:review", "invoice/one?details", ...context], { from: "user" });
expect(fetch).toHaveBeenCalledOnce();
const [url, options] = fetch.mock.calls[0];
expect(url).toBe("http://paperclip.test/api/companies/test-company/accounting/invoices/invoice%2Fone%3Fdetails");
expect(options.method).toBe("GET");
expect(options.body).toBeUndefined();
});
});
+45 -12
View File
@@ -10,6 +10,9 @@ import {
interface CompanyOptions extends BaseClientOptions {
companyId?: string;
from?: string;
to?: string;
allTime?: boolean;
}
interface JsonPayloadOptions extends CompanyOptions {
@@ -33,7 +36,7 @@ export function registerCostCommands(program: Command): void {
["window-spend", "costs/window-spend"],
["quota-windows", "costs/quota-windows"],
] as const) {
addCompanyGet(cost, name, `Get ${name} cost data`, path);
addCompanyGet(cost, name, `Get ${name} cost data`, path, name !== "window-spend" && name !== "quota-windows");
}
addCommonClientOptions(
@@ -54,12 +57,33 @@ export function registerCostCommands(program: Command): void {
addCompanyPostJson(cost, "event:create", "Record a cost event", "cost-events");
const accounting = program.command("accounting").description("Inspect and reconcile company accounting");
addCompanyGet(accounting, "health", "Show pending receipts, pricing and reservations", "accounting/health");
addCompanyGet(accounting, "inspect", "Read-only comparison of ledger and projections", "accounting/inspect");
addCompanyGet(accounting, "invoices", "List imported provider invoices", "accounting/invoices");
addCompanyPostJson(accounting, "repair", "Repair reviewed projections with inspection fingerprint and reason", "accounting/repair");
addCompanyPostJson(accounting, "retry", "Retry accounting for a company run", "accounting/retry");
addCompanyPostJson(accounting, "provider:import", "Import a provider cost report using a company admin credential", "accounting/provider-costs/import");
addCompanyPostJson(accounting, "invoice:import", "Import a normalized billing invoice JSON", "accounting/invoices");
for (const [name, , method] of [["invoice:review", "invoices", "get"], ["event:correct", "events", "post"]] as const) {
addCommonClientOptions(accounting.command(name).argument("<id>").option("-C, --company-id <id>", "Company ID")
.option("--payload-json <json>", "Correction JSON payload", "{}")
.action(async (id: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const url = method === "get" ? apiPath`/api/companies/${ctx.companyId}/accounting/invoices/${id}` : apiPath`/api/companies/${ctx.companyId}/accounting/events/${id}/adjustments`;
const result = method === "get" ? await ctx.api.get(url) : await ctx.api.post(url, parseJson(opts.payloadJson));
printOutput(result, { json: ctx.json });
} catch (error) { handleCommandError(error); }
}), { includeCompany: false });
}
const finance = program.command("finance").description("Finance event and summary operations");
addCompanyPostJson(finance, "event:create", "Record a finance event", "finance-events");
addCompanyGet(finance, "events", "List finance events", "costs/finance-events");
addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary");
addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller");
addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind");
addCompanyGet(finance, "events", "List finance events", "costs/finance-events", true);
addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary", true);
addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller", true);
addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind", true);
const budget = program.command("budget").description("Budget policy and incident operations");
addCompanyGet(budget, "overview", "Get budget overview", "budgets/overview");
@@ -123,16 +147,25 @@ export function registerCostCommands(program: Command): void {
);
}
function addCompanyGet(parent: Command, name: string, description: string, path: string): void {
function addCompanyGet(parent: Command, name: string, description: string, path: string, dateRange = false): void {
const command = parent.command(name).description(description).option("-C, --company-id <id>", "Company ID");
if (dateRange) {
command
.option("--from <iso-date>", "Inclusive start date (default: UTC month start; unbounded with --to alone)")
.option("--to <iso-date>", "Inclusive end timestamp (date-only values stop at 00:00 UTC that day)")
.option("--all-time", "Report all recorded history");
}
addCommonClientOptions(
parent
.command(name)
.description(description)
.option("-C, --company-id <id>", "Company ID")
.action(async (opts: CompanyOptions) => {
command.action(async (opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`);
const params = new URLSearchParams();
if (opts.allTime && (opts.from || opts.to)) throw new Error("--all-time cannot be combined with --from or --to");
if (opts.allTime) params.set("period", "all");
if (opts.from) params.set("from", opts.from);
if (opts.to) params.set("to", opts.to);
const suffix = params.size ? `?${params}` : "";
const result = await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}${suffix}`);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
+20
View File
@@ -134,6 +134,26 @@ and sending use local fixtures. The live `/chats` and `/chats/:agentRef` routes
use the same sidebar, picker, landing page, and existing chat surface when the
Agent Chat experimental setting is enabled.
The dev server listens on all network interfaces. From another computer, open
`http://<this-machine-hostname>:6006/` using its local network name, `.local`
name, or LAN/Tailscale IP. The machine's full hostname, short hostname, and
short hostname plus `.local` are allowed automatically. For another DNS alias,
set a comma- or whitespace-separated list of exact hostnames:
```sh
PAPERCLIP_STORYBOOK_ALLOWED_HOSTS=preview.example.internal,example.tail123.ts.net pnpm storybook
```
Entries cannot include schemes, ports, paths, or wildcard suffixes. Storybook's
host validation applies to the manager, Vite preview, and live-reload websocket
connections; arbitrary hostnames are rejected to prevent DNS rebinding.
Use **Product → Costs → Overview** to review the shared Costs page in its
default streamlined layout, including reported, estimated and partially
estimated agent costs, subscription runs, and project-attributed run costs.
The illustrative data stays in Storybook. The legacy standalone route uses the
same page; its heading, breadcrumbs and Budgets tab are covered by UI tests.
Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance**
to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the
next question. Reduced-motion mode advances without animation.
+223
View File
@@ -0,0 +1,223 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, test, type APIResponse } from "@playwright/test";
async function json(response: APIResponse) {
expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true);
return response.json();
}
test.use({ trace: "retain-on-failure" });
test("provider quotas keep accounts separate, preserve unknown usage, and retain readings through refresh failures", async ({ page, request }, info) => {
const company = await json(await request.post("/api/companies", { data: { name: `Quota review ${Date.now()}` } }));
const api = `/api/companies/${company.id}`;
const capturedAt = new Date().toISOString();
const accounts = [
{ provider: "openai", accountKey: "personal", accountLabel: "Personal subscription", ok: true, capturedAt,
windows: [{ label: "5h", usedPercent: 42, resetsAt: null, valueLabel: null }] },
{ provider: "openai", accountKey: "team", accountLabel: "Team subscription", ok: true, capturedAt,
windows: [{ label: "7d", usedPercent: null, resetsAt: null, valueLabel: null }] },
];
let refreshFails = false;
try {
const agent = await json(await request.post(`${api}/agents`, { data: { name: "Quota fixture", role: "engineer", adapterType: "process" } }));
await json(await request.post(`${api}/cost-events`, { data: { agentId: agent.id, provider: "openai", model: "gpt-5",
billingType: "metered_api", costCents: 125, inputTokens: 100, outputTokens: 10, occurredAt: capturedAt } }));
// Exercise the actual page with deterministic provider responses; no live
// account credential or quota service is involved in screenshot evidence.
await page.route(`**${api}/costs/quota-windows`, route => route.fulfill({ json: refreshFails
? accounts.map(account => ({ ...account, ok: false, windows: [], errorFamily: "provider_unavailable", error: "raw provider command failed: secret diagnostic" }))
: accounts }));
await page.setViewportSize({ width: 1440, height: 1000 });
await page.clock.install();
await page.goto(`/${company.issuePrefix}/activity/costs`);
await page.getByRole("tab", { name: "Providers", exact: true }).click();
const personal = page.locator("section").filter({ has: page.getByText("Personal subscription", { exact: true }) });
const team = page.locator("section").filter({ has: page.getByText("Team subscription", { exact: true }) });
const providerCard = page.locator('[data-slot="card"]').filter({ has: personal });
// Announcement timing is unrelated to quota behavior; keep its floating
// card from covering the evidence when the provider card scrolls into view.
const screenshotOptions = { style: '[aria-label="Paperclip announcements"] { visibility: hidden; }' };
await expect(personal.getByRole("progressbar", { name: "5h: 42%" })).toBeVisible();
await expect(team.getByText("Usage not reported", { exact: true })).toBeVisible();
await expect(team.getByRole("progressbar")).toHaveCount(0);
await info.attach("provider-quota-multiple-accounts-and-unknown-usage", { body: await providerCard.screenshot(screenshotOptions), contentType: "image/png" });
refreshFails = true;
const refresh = page.waitForResponse(response => response.url().endsWith("/costs/quota-windows"));
await page.clock.fastForward(300_001);
await refresh;
await expect(personal.getByRole("progressbar", { name: "5h: 42%" })).toBeVisible();
await expect(team.getByText("Usage not reported", { exact: true })).toBeVisible();
await expect(personal.getByText("Showing the last available quota. Updates will resume automatically.", { exact: true })).toBeVisible();
await expect(page.getByText("raw provider command failed", { exact: false })).toHaveCount(0);
await info.attach("provider-quota-refresh-failure-retains-readings", { body: await providerCard.screenshot(screenshotOptions), contentType: "image/png" });
} finally {
await request.delete(api);
}
});
test("existing Finance reports display API charges and credits without the deferred entry tools", async ({ page, request }, info) => {
const company = await json(await request.post("/api/companies", { data: { name: `Finance entry ${Date.now()}` } }));
const api = `/api/companies/${company.id}`;
try {
await page.goto(`/${company.issuePrefix}/activity/costs`);
await expect(page.getByRole("tab", { name: "Finance", exact: true })).toBeVisible();
await expect(page.getByText("Recent financial events", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Record or import charges" })).toHaveCount(0);
await expect(page.getByText("Accounting health", { exact: true })).toHaveCount(0);
// Existing integrations keep using the original finance-events endpoint.
const charge = { biller: "anthropic", eventKind: "platform_fee", direction: "debit", currency: "USD", occurredAt: new Date().toISOString() };
const saved = await request.post(`${api}/finance-events`, { data: { ...charge,
idempotencyKey: "browser-subscription", amountCents: "2012.3456789", description: "Browser subscription" } });
expect(saved.status()).toBe(201);
await json(await request.post(`${api}/finance-events`, { data: { ...charge,
eventKind: "credit_refund", direction: "credit", amountCents: "200", description: "Subscription refund" } }));
await json(await request.post(`${api}/finance-events`, { data: { ...charge,
currency: "EUR", amountCents: "300", description: "European platform fee" } }));
const invoice = { biller: "openai", externalId: "browser-finance-invoice", currency: "USD", lines: [{ externalId: "fee", kind: "fee", amountCents: "100", occurredAt: new Date().toISOString() }] };
await json(await request.post(`${api}/accounting/invoices`, { data: invoice }));
await json(await request.post(`${api}/accounting/invoices`, { data: invoice }));
const rows = await json(await request.get(`${api}/costs/finance-events`));
expect(rows).toHaveLength(4);
expect(rows.find((row: { description: string }) => row.description === "Browser subscription").amountCentsExact).toBe("2012.3456789");
expect((await json(await request.get(`${api}/costs/summary`))).spendCents).toBe(0);
expect(await json(await request.get(`${api}/costs/finance-summary`))).toMatchObject({
debitCentsExact: "2112.3456789", creditCentsExact: "200.0000000", netCentsExact: "1912.3456789", eventCount: 3,
currencies: expect.arrayContaining([expect.objectContaining({ currency: "EUR", netCentsExact: "300.0000000" })]),
});
await page.reload();
const headline = page.locator('[data-slot="card"]').filter({ has: page.getByText("Recorded charges", { exact: true }) });
await expect(headline.getByText("$19.12", { exact: true })).toBeVisible();
await expect(headline.getByText("$21.12 debits", { exact: true })).toBeVisible();
await expect(headline.getByText("$2.00 credits", { exact: true })).toBeVisible();
await expect(page.getByText("Recent financial events", { exact: true })).toHaveCount(0);
await expect(page.getByText("Browser subscription", { exact: true })).toHaveCount(0);
await page.getByRole("tab", { name: "Finance", exact: true }).click();
for (const label of ["Finance ledger", "By biller", "Financial event mix", "Recent financial events", "Browser subscription", "Subscription refund", "European platform fee"]) {
await expect(page.getByText(label, { exact: true })).toBeVisible();
}
await expect(page.getByText("Finance headline totals are USD only.", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "Record or import charges" })).toHaveCount(0);
await expect(page.getByText("Accounting health", { exact: true })).toHaveCount(0);
await info.attach("existing-finance-reports", { body: await page.screenshot(), contentType: "image/png" });
} finally {
await request.delete(api);
}
});
test("real runs reach the ledger, stop at budget, and resume after a UI grant", async ({ page, request }, info) => {
test.setTimeout(150_000);
const root = await mkdtemp(path.join(os.tmpdir(), "accounting-browser-"));
const command = path.join(root, "accounting-provider.mjs");
const calls = path.join(root, "calls");
// Exercise the production Claude adapter and heartbeat lifecycle without
// credentials, network calls, provider variability, or paid inference.
await writeFile(command, `#!/usr/bin/env node
import { appendFileSync } from 'node:fs';
if (process.argv.includes('--version')) { console.log('2.1.0'); process.exit(0); }
for await (const chunk of process.stdin) {}
appendFileSync(${JSON.stringify(calls)}, 'run\\n');
console.log(JSON.stringify({ type: 'result', subtype: 'success', is_error: false,
result: 'Accounting fixture completed.', total_cost_usd: 0.015,
usage: { input_tokens: 7, cache_read_input_tokens: 11, output_tokens: 3 } }));
`, { mode: 0o755 });
const company = await json(await request.post("/api/companies", { data: { name: `Accounting browser ${Date.now()}` } }));
const companyApi = `/api/companies/${company.id}`;
try {
const agent = await json(await request.post(`${companyApi}/agents`, { data: {
name: "Accounting fixture", role: "engineer", adapterType: "claude_local",
adapterConfig: { engine: "cli", cwd: root, command, model: "claude-sonnet-4-6", env: { ANTHROPIC_API_KEY: "accounting-fixture-no-network" } },
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
} }));
await json(await request.post(`${companyApi}/budgets/policies`, { data: {
scopeType: "agent", scopeId: agent.id, amount: 2, notifyEnabled: false,
} }));
const summary = async () => json(await request.get(`${companyApi}/costs/summary`));
const callCount = async () => (await readFile(calls, "utf8").catch(() => "")).trim().split("\n").filter(Boolean).length;
async function runFromBrowser(expectedCents: number) {
await page.goto(`/${company.issuePrefix}/agents/${agent.id}`);
const invoked = page.waitForResponse(response => response.url().includes("/heartbeat/invoke") && response.request().method() === "POST");
await page.getByRole("button", { name: "Run now", exact: true }).click();
const response = await invoked;
expect(response.ok(), await response.text()).toBe(true);
const run = await response.json();
expect(run.id).toBeTruthy();
await expect.poll(async () => (await json(await request.get(`/api/heartbeat-runs/${run.id}`))).status,
{ timeout: 40_000 }).toBe("succeeded");
await expect.poll(async () => (await summary()).spendCents).toBe(expectedCents);
return run.id as string;
}
const first = await runFromBrowser(1.5);
const second = await runFromBrowser(3);
expect(first).not.toBe(second);
await expect.poll(async () => (await json(await request.get(`/api/agents/${agent.id}`))).status).toBe("paused");
expect(await callCount()).toBe(2);
// A manual board wake must respect the same stop as background scheduling.
const blocked = await request.post(`/api/agents/${agent.id}/heartbeat/invoke`, { data: {} });
expect(blocked.status()).toBe(409);
const blockedBody = await blocked.json();
expect(blockedBody.id).toBeUndefined();
expect(blockedBody.error).toMatch(/budget|paused/i);
expect(await callCount()).toBe(2);
await page.goto(`/${company.issuePrefix}/costs`);
await expect(page.getByText("Spending reached $0.03 against a limit of $0.02.", { exact: true })).toBeVisible();
const spend = page.locator('[data-slot="card"]').filter({ has: page.getByText("Inference spend", { exact: true }) });
await expect(spend.getByText("$0.03", { exact: true })).toBeVisible();
await info.attach("budget-stopped", { body: await page.screenshot(), contentType: "image/png" });
const incident = page.locator('[data-slot="card"]').filter({ has: page.getByRole("button", { name: "Raise budget & resume", exact: true }) });
await incident.getByPlaceholder("0.00").fill("0.10");
const resolved = page.waitForResponse(response => response.url().includes("/budget-incidents/") && response.url().endsWith("/resolve"));
await incident.getByRole("button", { name: "Raise budget & resume", exact: true }).click();
expect((await resolved).ok()).toBe(true);
await expect(page.getByRole("button", { name: "Raise budget & resume", exact: true })).toHaveCount(0);
await expect.poll(async () => (await json(await request.get(`/api/agents/${agent.id}`))).status).toBe("idle");
await runFromBrowser(4.5);
expect(await callCount()).toBe(3);
const totals = await summary();
expect(totals).toMatchObject({ spendCents: 4.5, eventCount: 3, pendingRunCount: 0, pricingComplete: true });
const byAgent = await json(await request.get(`${companyApi}/costs/by-agent`));
expect(byAgent).toEqual([expect.objectContaining({ agentId: agent.id, costCents: 4.5, inputTokens: 21, cachedInputTokens: 33, outputTokens: 9 })]);
await page.goto(`/${company.issuePrefix}/costs`);
await expect(spend.getByText("$0.05", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Raise budget & resume", exact: true })).toHaveCount(0);
await info.attach("budget-resumed", { body: await page.screenshot(), contentType: "image/png" });
// Recovery and invoice review remain operator APIs; their UI is deferred.
expect((await json(await request.get(`${companyApi}/accounting/inspect`))).findings).toEqual([]);
const invoice = await json(await request.post(`${companyApi}/accounting/invoices`, { data: { biller: "anthropic", externalId: "browser-invoice", currency: "USD", lines: [{
externalId: "first-run", amountCents: "2.0000001", occurredAt: new Date().toISOString(), runId: first,
}] } }));
const review = await json(await request.get(`${companyApi}/accounting/invoices/${invoice.id}`));
const line = review.lines[0];
expect(line).toMatchObject({ status: "difference", recordedCents: "1.5000000", differenceCents: "0.5000001" });
const correction = { idempotencyKey: `invoice-line:${line.id}`, invoiceLineId: line.id,
expectedCents: line.recordedCents, correctedCents: line.amountCents,
reason: "Verified against the fixture provider invoice", pricing: { source: "provider_invoice", evidence: invoice.externalId } };
const correctionPath = `${companyApi}/accounting/events/${line.matchedEventId}/adjustments`;
expect((await request.post(correctionPath, { data: { ...correction, reason: "" } })).status()).toBe(400);
expect((await summary()).spendCentsExact).toBe("4.5000000");
await json(await request.post(correctionPath, { data: correction }));
await json(await request.post(correctionPath, { data: correction }));
await expect.poll(async () => (await summary()).spendCentsExact).toBe("5.0000001");
expect((await json(await request.get(`${companyApi}/accounting/inspect`))).findings).toEqual([]);
expect((await summary()).eventCount).toBe(3);
expect(await callCount()).toBe(3);
const history = await json(await request.get(`${companyApi}/accounting/events/${review.lines[0].matchedEventId}/adjustments`));
expect(history).toEqual([expect.objectContaining({
previousCents: "1.5000000", correctedCents: "2.0000001",
previousPricing: { source: "provider_reported", version: "accounting-receipt/v1" },
pricing: { source: "provider_invoice", evidence: "browser-invoice" },
})]);
await page.reload();
await expect(spend.getByText("$0.05", { exact: true })).toBeVisible();
await info.attach("operator-corrected-costs", { body: await page.screenshot(), contentType: "image/png" });
} finally {
await request.patch(`${companyApi}`, { data: { status: "archived" } });
await rm(root, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -17,7 +17,7 @@
"dev": "vite",
"build": "vite build",
"build:preview": "vite build --config vite.preview.config.mjs",
"storybook": "storybook dev -p 6006 -c storybook/.storybook",
"storybook": "storybook dev --host 0.0.0.0 -p 6006 -c storybook/.storybook",
"build-storybook": "storybook build -c storybook/.storybook -o storybook-static",
"preview": "vite preview",
"typecheck": "tsc -b",
@@ -0,0 +1,26 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { formatDateTime } from "../lib/utils";
import { AccountQuotaPanels } from "./AccountQuotaPanels";
describe("account quota panels", () => {
it("renders both accounts for one provider without merging their windows", () => {
const html = renderToStaticMarkup(<AccountQuotaPanels accounts={[
{ provider: "openai", accountKey: "a", accountLabel: "Personal", ok: true, windows: [{ label: "Personal limit", usedPercent: 25, resetsAt: null, valueLabel: null }] },
{ provider: "openai", accountKey: "b", accountLabel: "Shared", ok: true, windows: [{ label: "Shared limit", usedPercent: 75, resetsAt: null, valueLabel: null }] },
]} />);
expect(html).toContain("Personal limit");
expect(html).toContain("Shared limit");
expect(html).toContain("25% used");
expect(html).toContain("75% used");
});
it("displays unknown utilization without inventing zero percent", () => {
const html = renderToStaticMarkup(<AccountQuotaPanels accounts={[
{ provider: "openai", accountKey: "a", ok: true, windows: [{ label: "Credits", usedPercent: null, resetsAt: "2026-10-05T00:00:00Z", valueLabel: "$5 remaining" }] },
]} />);
expect(html).toContain("$5 remaining");
expect(html).toContain(`Resets ${formatDateTime("2026-10-05T00:00:00Z")}`);
expect(html).not.toContain("0%");
expect(html).not.toContain('role="progressbar"');
});
});
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BudgetPolicySummary } from "@paperclipai/shared";
import { BudgetPolicyCard } from "./BudgetPolicyCard";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("exact reservation editing", () => {
const container = document.createElement("div");
let root: ReturnType<typeof createRoot>;
afterEach(async () => { await act(async () => root.unmount()); container.remove(); });
it.each(["card", "plain"] as const)("keeps %s advanced settings collapsed and preserves exact reservation edits", async (variant) => {
const summary: BudgetPolicySummary = {
policyId: "policy", companyId: "company", scopeType: "company", scopeId: "company", scopeName: "Company",
metric: "billed_cents", windowKind: "calendar_month_utc", amount: 100, observedAmount: 0,
observedAmountExact: "0.0000000", remainingAmount: 100, utilizationPercent: 0, warnPercent: 80,
unpricedEventCount: 0, pendingRunCount: 0, unpricedUsagePolicy: "block", reservationCents: "9007199254740992.0000001",
hardStopEnabled: true, notifyEnabled: true, isActive: true, status: "ok", paused: false, pauseReason: null,
windowStart: new Date("2026-09-01"), windowEnd: new Date("2026-10-01"),
};
const onReservationChange = vi.fn();
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<BudgetPolicyCard summary={summary} variant={variant} onReservationChange={onReservationChange} />));
const advanced = container.querySelector("details")!;
expect(advanced.open).toBe(false);
expect(onReservationChange).not.toHaveBeenCalled();
await act(async () => advanced.querySelector("summary")!.click());
expect(advanced.open).toBe(true);
const input = container.querySelector<HTMLInputElement>('[aria-label="Reserve per run (USD)"]')!;
const save = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Update reservation")!;
expect(input.value).toBe("90071992547409.920000001");
expect(save.disabled).toBe(true);
for (const invalid of ["-1", "", "not an amount"]) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, invalid);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(save.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Enter a valid amount of zero or more.");
expect(input.getAttribute("aria-describedby")).toBe(container.querySelector('[role="alert"]')?.id);
}
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "0.000000001");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => save.click());
expect(onReservationChange).toHaveBeenCalledWith("0.0000001");
expect(container.querySelector('[role="alert"]')).toBeNull();
});
});
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BillerSpendCard } from "./BillerSpendCard";
import { ProviderQuotaCard } from "./ProviderQuotaCard";
describe("provider cost shares", () => {
it("shows a safe refresh failure when this provider has no subscription accounts", () => {
const html = renderToStaticMarkup(<ProviderQuotaCard provider="openai" rows={[]} quotaAccounts={[]}
quotaError="provider returned a sensitive raw error" quotaRequestFailed
budgetMonthlyCents={0} totalCompanySpendCents={0} weekSpendCents={0} windowRows={[]} showDeficitNotch={false} />);
expect(html).toContain('role="status"');
expect(html).toContain("Subscription quota is currently unavailable");
expect(html).not.toContain("sensitive raw error");
});
it("counts subscription tokens once and compares provider spend to the company budget", () => {
const html = renderToStaticMarkup(<ProviderQuotaCard provider="anthropic" budgetMonthlyCents={1000} totalCompanySpendCents={500}
weekSpendCents={0} windowRows={[]} showDeficitNotch={false} rows={[{
provider: "anthropic", biller: "anthropic", model: "test", billingType: "subscription_overage",
costCents: 500, inputTokens: 10, cachedInputTokens: 1000, outputTokens: 20,
apiRunCount: 0, subscriptionRunCount: 1, subscriptionInputTokens: 10, subscriptionCachedInputTokens: 1000, subscriptionOutputTokens: 20,
}]} />);
expect(html).toContain("100% of token usage via subscription");
expect(html).toContain("50% of company budget");
expect(html).not.toContain("of allocation");
});
it("renders normalized mixed-era OpenAI totals consistently across models, windows, and subscription shares", () => {
// The report API combines one inclusive historical row and one exclusive
// receipt-backed row into 40 ordinary + 160 cached + 20 output per group.
const rows = (["metered_api", "subscription_included"] as const).map(billingType => ({
provider: "openai", biller: "openai", model: `test-${billingType}`, billingType,
costCents: 18, inputTokens: 40, cachedInputTokens: 160, outputTokens: 20,
apiRunCount: billingType === "metered_api" ? 2 : 0,
subscriptionRunCount: billingType === "subscription_included" ? 2 : 0,
subscriptionInputTokens: billingType === "subscription_included" ? 40 : 0,
subscriptionCachedInputTokens: billingType === "subscription_included" ? 160 : 0,
subscriptionOutputTokens: billingType === "subscription_included" ? 20 : 0,
}));
const html = renderToStaticMarkup(<ProviderQuotaCard provider="openai" rows={rows}
budgetMonthlyCents={0} totalCompanySpendCents={36} weekSpendCents={36} showDeficitNotch={false}
windowRows={[{ provider: "openai", biller: "openai", window: "5h", windowHours: 5,
costCents: 36, inputTokens: 80, cachedInputTokens: 320, outputTokens: 40 }]} />);
expect(html).toContain('>400</span> in');
expect(html).toContain('>40</span> out');
expect(html.match(/220 tok/g)).toHaveLength(2);
expect(html).toContain("440 tok");
expect(html).toContain("50% of token usage via subscription");
expect(html.match(/title="50% of provider tokens"/g)).toHaveLength(2);
expect(html).toContain("$0.36");
});
it("hides historical spend-to-monthly-budget comparisons for providers and billers", () => {
const common = { budgetMonthlyCents: 1000, totalCompanySpendCents: 20000, weekSpendCents: 0, showBudgetUtilization: false };
const provider = renderToStaticMarkup(<ProviderQuotaCard {...common} provider="openai" rows={[]} windowRows={[]} showDeficitNotch={false} />);
const biller = renderToStaticMarkup(<BillerSpendCard {...common} providerRows={[]} row={{ biller: "openai", costCents: 20000, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, apiRunCount: 0, subscriptionRunCount: 0, providerCount: 1, modelCount: 1, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 }} />);
for (const html of [provider, biller]) {
expect(html).not.toContain("Period spend");
expect(html).not.toContain("of allocation");
expect(html).not.toContain("of company budget");
}
});
it("compares biller spend with the actual company cap", () => {
const html = renderToStaticMarkup(<BillerSpendCard budgetMonthlyCents={1000} totalCompanySpendCents={800} weekSpendCents={0} providerRows={[]} row={{ biller: "openai", costCents: 200, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, apiRunCount: 0, subscriptionRunCount: 0, providerCount: 1, modelCount: 1, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 }} />);
expect(html).toContain("20% of company budget");
expect(html).not.toContain("80% of allocation");
});
});
@@ -16,6 +16,24 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
import { queryKeys } from "../lib/queryKeys";
describe("LiveUpdatesProvider issue invalidation", () => {
it.each(["cost_event", "heartbeat"])("refreshes every selected costs period within the company on %s", (source) => {
const client = new QueryClient();
const keys = (companyId: string) => [
queryKeys.costs(companyId, undefined, "all"),
queryKeys.costs(companyId, "2026-10-01", "mtd"),
[...queryKeys.costs(companyId, undefined, "all"), "by-user"],
queryKeys.usageByProvider(companyId, undefined, "all"),
queryKeys.usageByBiller(companyId, undefined, "all"),
];
for (const key of [...keys("company-1"), ...keys("company-2")]) client.setQueryData(key, {});
if (source === "cost_event") {
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { entityType: "cost_event", entityId: "charge", action: "cost.created" }, { userId: "user", agentId: null });
} else __liveUpdatesTestUtils.invalidateHeartbeatQueries(client, "company-1", { runId: "run", agentId: "agent" });
for (const key of keys("company-1").slice(0, source === "cost_event" ? 5 : 3)) expect(client.getQueryState(key)?.isInvalidated).toBe(true);
for (const key of keys("company-2")) expect(client.getQueryState(key)?.isInvalidated).toBe(false);
client.clear();
});
it.each([
["chat-1", "issue.comment_added", "agent", "agent-1", true],
["task-1", "issue.comment_added", "agent", "agent-1", false],
+18
View File
@@ -0,0 +1,18 @@
import { expect, it, vi } from "vitest";
import { computeRange } from "./useDateRange";
it.each([
["mtd", "2026-10-01T00:30:00.000Z", "2026-10-01T00:00:00.000Z"],
["ytd", "2026-01-01T00:30:00.000Z", "2026-01-01T00:00:00.000Z"],
] as const)("starts %s at the UTC boundary even before local midnight", (preset, now, from) => {
vi.stubEnv("TZ", "Pacific/Honolulu");
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date(now));
expect(new Date().getTimezoneOffset()).toBe(600);
expect(computeRange(preset)).toEqual({ from, to: now });
} finally {
vi.useRealTimers();
vi.unstubAllEnvs();
}
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { formatCents, visibleRunCostUsd, visibleRunTokenTotal } from "./utils";
describe("cost presentation", () => {
it("keeps historical inclusive input totals and counts receipt-backed cache hits once", () => {
expect(visibleRunTokenTotal({ provider: "openai", inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 })).toBe(110);
expect(visibleRunTokenTotal({ provider: "openai", inputTokens: 20, cachedInputTokens: 80, outputTokens: 10, accountingReceiptId: "saved-receipt" })).toBe(110);
expect(visibleRunTokenTotal({ input_tokens: 100, cached_input_tokens: 80, output_tokens: 10 })).toBe(110);
expect(visibleRunTokenTotal({ input_tokens: 20, cache_read_input_tokens: 80, output_tokens: 10, accountingReceiptId: "saved-receipt" })).toBe(110);
expect(visibleRunTokenTotal(null)).toBe(0);
});
it("shows the adjusted charge and respects an explicit zero", () => {
expect(visibleRunCostUsd({ costUsd: 3.1, cacheAdjustedCostUsd: 1.5 })).toBe(1.5);
expect(visibleRunCostUsd({ cacheAdjustedCostUsd: 0 }, { costUsd: 5 })).toBe(0);
expect(visibleRunCostUsd({ cacheAdjustedCostUsd: 0.004 })).toBe(0.004);
expect(visibleRunCostUsd({ costUsd: -1 }, { costUsd: 2 })).toBe(2);
expect(visibleRunCostUsd({ billingType: "subscription_included", costUsd: 50 })).toBe(0);
});
it.each(["anthropic", "google"])("includes separate legacy %s cache reads without changing legacy Codex totals", (provider) => {
const tokens = { inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 };
expect(visibleRunTokenTotal({ ...tokens, provider })).toBe(190);
expect(visibleRunTokenTotal({ ...tokens, provider: "openai" })).toBe(110);
expect(visibleRunTokenTotal({ ...tokens, provider, accountingReceiptId: "saved-receipt" })).toBe(190);
expect(visibleRunTokenTotal({ provider, input_tokens: 100, cache_read_input_tokens: 80, output_tokens: 10 })).toBe(190);
});
it("formats finance amounts in their recorded currency", () => {
expect(formatCents(123, "USD")).toBe("$1.23");
expect(formatCents(123, "EUR")).toBe("€1.23");
expect(formatCents(123, "123")).toBe("123 1.23");
expect(formatCents(-123, " X ")).toBe(" X -1.23");
});
it("shows exact estimated run charges in agent and issue views", () => {
expect(visibleRunCostUsd({ costUsdExact: "0.001234", costUsd: null })).toBe(0.001234);
expect(visibleRunCostUsd(null, { costUsdExact: "12.34" })).toBe(12.34);
expect(visibleRunCostUsd({ costUsdExact: "0", costUsd: 4 })).toBe(0);
expect(visibleRunCostUsd({ costUsdExact: "3", cacheAdjustedCostUsd: 2 })).toBe(2);
expect(visibleRunCostUsd({ costUsdExact: "-1", costUsd: 2 })).toBe(2);
expect(visibleRunCostUsd({ costUsdExact: "", costUsd: 2 })).toBe(2);
expect(visibleRunCostUsd({ costUsdExact: "Infinity", costUsd: 2 })).toBe(2);
expect(visibleRunCostUsd({ costUsdExact: "20", billingType: "subscription_included" })).toBe(0);
});
it.each(["openai", "openrouter", "opencode", "xai"])("recognizes saved OpenCode/Pi %s model layouts without changing legacy Codex", (provider) => {
const tokens = { inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 };
expect(visibleRunTokenTotal({ ...tokens, provider, model: `${provider}/vendor/model` })).toBe(190);
expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "gpt-5" })).toBe(110);
expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "different/model" })).toBe(110);
expect(visibleRunTokenTotal({ ...tokens, provider: "openai", model: "openai/" })).toBe(110);
expect(visibleRunTokenTotal({ ...tokens, provider: "unknown", model: "unknown/model" })).toBe(110);
expect(visibleRunTokenTotal({ ...tokens, inputTokens: 20, provider: "openai", model: "gpt-5", accountingReceiptId: "saved" })).toBe(110);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { ProviderQuotaResult } from "@paperclipai/shared";
import { ProviderQuotaCard } from "../components/ProviderQuotaCard";
import { retainQuotaWindows } from "./quota-refresh";
const windows = [{ label: "5h limit", usedPercent: 37, resetsAt: null, valueLabel: null }];
const previous: ProviderQuotaResult[] = [
{ provider: "openai", source: "codex-rpc", ok: true, windows },
{ provider: "anthropic", source: "anthropic-oauth", ok: true, windows },
];
describe("provider quota refresh", () => {
it("keeps account identity and original capture time across failures", () => {
const prior = [{ ...previous[0], accountKey: "account-a", capturedAt: "2026-09-01T00:00:00Z" }];
const failed = { provider: "openai", accountKey: "account-a", ok: false, windows: [] };
expect(retainQuotaWindows(prior, [failed])[0]).toMatchObject({ windows, capturedAt: prior[0].capturedAt });
expect(retainQuotaWindows(prior, [{ ...failed, accountKey: "account-b" }])[0].windows).toEqual([]);
expect(retainQuotaWindows(prior, [])).toEqual([]);
});
it("retains failed provider windows through repeated failures while updating successful providers", () => {
const incoming: ProviderQuotaResult[] = [
{ provider: "openai", ok: false, error: "failed", windows: [] },
{ provider: "anthropic", ok: true, windows: [] },
];
const refreshed = retainQuotaWindows(previous, incoming);
expect(refreshed[0]).toEqual({ ...incoming[0], source: "codex-rpc", windows });
expect(refreshed[1].windows).toEqual([]);
expect(retainQuotaWindows(refreshed, incoming)[0].windows).toEqual(windows);
expect(incoming[0].windows).toEqual([]);
});
it("does not invent or copy windows for providers with no previous success", () => {
const incoming = [{ provider: "unknown", ok: false, error: "failed", windows: [] }];
expect(retainQuotaWindows(previous, incoming)).toEqual(incoming);
expect(retainQuotaWindows(undefined, incoming)).toEqual(incoming);
});
it.each(["refresh_token_reused", "refresh_token_expired", "refresh_token_invalidated", "credentials_unavailable", "authentication_required"])(
"discards cached windows after credentials are rejected (%s)",
(errorFamily) => {
const transientFailure = [{ provider: "openai", ok: false, windows: [], error: "unavailable" }];
const stale = retainQuotaWindows(previous, transientFailure);
const rejected = [{ ...transientFailure[0], errorFamily }];
expect(retainQuotaWindows(stale, rejected)).toEqual(rejected);
// Later network failures must not restore the invalidated account's data.
expect(retainQuotaWindows(rejected, transientFailure)[0].windows).toEqual([]);
expect(previous[0].windows).toEqual(windows);
},
);
it.each([
["anthropic", "claude-cli"],
["openai", "codex-rpc"],
["openai", null],
])("never renders raw quota errors for %s / %s", (provider, source) => {
for (const quotaWindows of [[], windows]) {
const html = renderToStaticMarkup(<ProviderQuotaCard
provider={provider!} rows={[]} budgetMonthlyCents={0} totalCompanySpendCents={0}
weekSpendCents={0} windowRows={[]} showDeficitNotch={false}
quotaSource={source} quotaWindows={quotaWindows}
quotaError={'Command failed: sh -c private-path --token private-secret'}
/>);
expect(html).not.toContain("Command failed");
expect(html).not.toContain("private-secret");
expect(html).not.toContain("text-destructive");
expect(html).toContain(quotaWindows.length ? "Showing the last available quota" : "Subscription quota is currently unavailable");
}
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Costs } from "./Costs";
import { SidebarProvider } from "../context/SidebarContext";
const api = vi.hoisted(() => Object.fromEntries([
"summary", "byUser", "byAgent", "byProject", "byAgentModel", "financeSummary", "financeByBiller",
"financeByKind", "financeEvents", "byProvider", "byBiller", "windowSpend", "quotaWindows",
].map(key => [key, vi.fn()])));
vi.mock("../api/costs", () => ({ costsApi: api }));
vi.mock("../api/budgets", () => ({ budgetsApi: { overview: async () => ({ policies: [], activeIncidents: [] }) } }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "quota-company" }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: () => {} }) }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe("Costs account quota polling", () => {
let container: HTMLDivElement, root: ReturnType<typeof createRoot>, client: QueryClient;
const queryKey = ["usage-quota-windows", "quota-company"];
const observed = { provider: "openai", accountKey: "credential-one", accountLabel: "Codie account", ok: true,
capturedAt: "2026-10-03T12:00:00Z", windows: [{ label: "5h limit", usedPercent: 37, resetsAt: null, valueLabel: null }] };
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
for (const mock of Object.values(api)) mock.mockReset().mockResolvedValue([]);
api.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
api.summary.mockResolvedValue({ spendCents: 0, budgetCents: 0 });
api.financeSummary.mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, eventCount: 0 });
api.byProvider.mockResolvedValue([{ provider: "openai", biller: "openai", model: "test", billingType: "metered_api", costCents: 0, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0 }]);
api.quotaWindows.mockResolvedValue([observed]);
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => { act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); };
it.each(["providers", "billers"] as const)("marks retained %s reports stale when their refresh fails", async (tab) => {
api.byBiller.mockResolvedValue([{ biller: "openai", costCents: 125, eventCount: 1, providerCount: 1, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0 }]);
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs initialTab={tab} /></QueryClientProvider></SidebarProvider></MemoryRouter>));
await flush();
expect(container.textContent).not.toContain("Showing the last loaded data");
const report = tab === "providers" ? api.byProvider : api.byBiller;
report.mockRejectedValue(new Error("Private report diagnostic"));
await act(async () => { await client.invalidateQueries(); }); await flush();
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).toContain("OpenAI");
expect(container.textContent).not.toContain("Private report diagnostic");
});
it("keeps quota readings with a safe notice when the endpoint rejects", async () => {
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs /></QueryClientProvider></SidebarProvider></MemoryRouter>));
await flush();
const tab = [...container.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(button => button.textContent === "Providers")!;
await act(async () => { tab.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 })); tab.click(); });
await flush();
expect(container.textContent).toContain("37% used");
api.quotaWindows.mockRejectedValue(new Error("Raw provider diagnostic: secret command"));
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
expect(container.textContent).toContain("37% used");
expect(container.textContent).toContain("Showing the last available quota");
expect(container.textContent).not.toContain("Raw provider diagnostic");
expect(client.getQueryData(queryKey)).toMatchObject([{ capturedAt: observed.capturedAt }]);
});
it.each(["provider_unavailable", "permission_denied"])("retains the observation on %s but clears rejected or rotated credentials", async (errorFamily) => {
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs initialTab="providers" /></QueryClientProvider></SidebarProvider></MemoryRouter>));
await flush();
expect(container.textContent).toContain("37% used");
api.quotaWindows.mockResolvedValue([{ ...observed, ok: false, windows: [], capturedAt: undefined, errorFamily }]);
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
expect(container.textContent).toContain("37% used");
expect(container.textContent).toContain("Showing the last available quota");
expect(client.getQueryData(queryKey)).toMatchObject([{ capturedAt: observed.capturedAt }]);
api.quotaWindows.mockResolvedValue([{ ...observed, ok: false, windows: [], errorFamily: "authentication_required" }]);
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
expect(container.textContent).not.toContain("37% used");
api.quotaWindows.mockResolvedValue([observed]);
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
api.quotaWindows.mockResolvedValue([{ ...observed, accountKey: "credential-two", ok: false, windows: [], errorFamily }]);
await act(async () => { await client.invalidateQueries({ queryKey }); }); await flush();
expect(container.textContent).not.toContain("37% used");
});
});
+396
View File
@@ -0,0 +1,396 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Costs, type CostsProps } from "./Costs";
import { SidebarProvider } from "../context/SidebarContext";
const state = vi.hoisted(() => ({ companyId: "company-1", breadcrumbs: vi.fn() }));
const overview = vi.hoisted(() => vi.fn());
const api = vi.hoisted(() => Object.fromEntries([
"summary", "byUser", "byAgent", "byProject", "byAgentModel", "financeSummary", "financeByBiller",
"financeByKind", "financeEvents", "byProvider", "byBiller", "windowSpend", "quotaWindows",
].map((key) => [key, vi.fn()])));
vi.mock("../api/costs", () => ({ costsApi: api }));
vi.mock("../api/budgets", () => ({ budgetsApi: { overview, upsertPolicy: vi.fn(), resolveIncident: vi.fn() } }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: state.breadcrumbs }) }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe.each([
["streamlined", { embedded: true, hideBudgetsTab: true }],
["standalone", {}],
] as const)("%s cost refresh", (name, props) => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let client: QueryClient;
async function settle() {
for (let i = 0; i < 3; i++) await act(async () => { await vi.advanceTimersByTimeAsync(1); });
}
async function render(overrides: CostsProps = {}) {
await act(async () => root.render(
<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs {...props} initialTab="overview" {...overrides} /></QueryClientProvider></SidebarProvider></MemoryRouter>,
));
await settle();
}
async function click(text: string) {
const button = [...document.querySelectorAll("button")].find((node) => node.textContent === text);
expect(button).toBeDefined();
await act(async () => {
if (button!.getAttribute("role") === "tab") {
button!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
}
button!.click();
});
await settle();
}
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
vi.setSystemTime(new Date("2026-09-29T12:00:10Z"));
state.companyId = "company-1";
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
for (const mock of Object.values(api)) mock.mockReset().mockResolvedValue([]);
api.byUser.mockResolvedValue({ activeUserCount: 1, rows: [] });
overview.mockReset().mockResolvedValue({ policies: [], activeIncidents: [], pendingApprovalCount: 0, pausedAgentCount: 0, pausedProjectCount: 0 });
api.summary.mockResolvedValue({ companyId: state.companyId, spendCents: 40288, budgetCents: 0, utilizationPercent: 0 });
api.financeSummary.mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 });
api.byAgent.mockResolvedValue([{
agentId: "agent-bender", agentName: "Bender", agentStatus: "idle", costCents: 40288,
inputTokens: 62100000, cachedInputTokens: 200000000, outputTokens: 2000000,
apiRunCount: 72, subscriptionRunCount: 0, subscriptionCachedInputTokens: 0,
subscriptionInputTokens: 0, subscriptionOutputTokens: 0,
}]);
api.byAgentModel.mockResolvedValue([{
agentId: "agent-bender", provider: "anthropic", biller: "anthropic", billingType: "api_metered",
model: "model-breakdown-fixture", costCents: 40288, inputTokens: 62100000,
cachedInputTokens: 200000000, outputTokens: 2000000,
}]);
});
afterEach(() => {
act(() => root.unmount());
client.clear();
container.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
});
const userReport = {
activeUserCount: 2,
rows: [
{ userId: "alice", userName: "Alice", userImage: null, eventCount: 2, estimatedEventCount: 1, unpricedEventCount: 0,
costCents: 123, costCentsExact: "123.0000000", inputTokens: 100, cachedInputTokens: 40, outputTokens: 10, runCount: 1 },
{ userId: "bob", userName: "Bob", userImage: null, eventCount: 0, estimatedEventCount: 0, unpricedEventCount: 0,
costCents: 0, costCentsExact: "0.0000000", inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, runCount: 0 },
{ userId: null, userName: null, userImage: null, eventCount: 1, estimatedEventCount: 0, unpricedEventCount: 1,
costCents: 0, costCentsExact: "0.0000000", inputTokens: 50, cachedInputTokens: 0, outputTokens: 10, runCount: 0 },
],
};
const userTable = () => container.querySelector<HTMLTableElement>('table[aria-label="Costs by user"]');
it("always shows user costs below agents, with estimates, unknown prices and an empty state", async () => {
await render();
expect(userTable()?.textContent).toContain("No user-attributed costs yet.");
api.byUser.mockResolvedValue(userReport);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); await settle();
const text = container.textContent!;
expect(text.indexOf("By agent")).toBeLessThan(text.indexOf("By user"));
expect(text.indexOf("By user")).toBeLessThan(text.indexOf("Finance ledger"));
expect(text).not.toContain("Inference ledger");
expect(text).toContain("Recorded charges");
expect(text).not.toContain("Recorded charges (net)");
expect(text).not.toContain("Finance net");
const rows = [...userTable()!.querySelectorAll("tbody tr")];
expect(rows[0].querySelector('[title="Alice"]')).not.toBeNull();
expect([...rows[0].children].slice(1).map(cell => cell.textContent)).toEqual(["1", "14040 cached", "10", "$1.23Partially estimated"]);
expect(rows[1].textContent).toContain("$0.00");
expect(rows[2].textContent).toContain("Unattributed");
expect(rows[2].textContent).toContain("—1 unpriced charge");
expect(rows[2].textContent).not.toContain("$0.00");
api.byUser.mockResolvedValue({ activeUserCount: 1, rows: [userReport.rows[0]] });
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); await settle();
expect(userTable()?.textContent).toContain("Alice");
expect(userTable()?.querySelectorAll("tbody tr")).toHaveLength(1);
api.byUser.mockResolvedValue({ activeUserCount: 0, rows: [] });
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); await settle();
expect(userTable()?.textContent).toContain("No user-attributed costs yet.");
});
it("retains the user table on a failed refresh and clears it when the company or dates change", async () => {
api.byUser.mockResolvedValue(userReport);
await render();
const table = userTable();
api.byUser.mockRejectedValue(new Error("private user SQL diagnostic"));
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); await settle();
expect(userTable()).toBe(table);
expect(userTable()?.textContent).toContain("Alice");
expect(container.textContent).toContain("User costs could not be refreshed");
expect(container.textContent).not.toContain("private user SQL diagnostic");
await click("7 Days");
expect(userTable()).toBeNull();
expect(api.byUser.mock.calls.at(-1)).toEqual(api.summary.mock.calls.at(-1));
expect(container.textContent).toContain("User costs could not be loaded");
expect(container.textContent).toContain("Bender");
api.byUser.mockResolvedValue(userReport);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); }); await settle();
expect(userTable()?.textContent).toContain("Alice");
state.companyId = "company-2";
api.byUser.mockRejectedValue(new Error("private other company error"));
await render();
expect(userTable()).toBeNull();
expect(container.textContent).not.toContain("Alice");
expect(api.byUser.mock.calls.at(-1)?.[0]).toBe("company-2");
});
it("keeps the existing Finance views without mounting or polling deferred accounting tools", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
await render();
for (const label of ["Recorded charges", "Finance events", "Finance ledger"]) {
expect(container.textContent).toContain(label);
}
expect(container.textContent).not.toContain("Recent financial events");
await click("Finance");
for (const label of ["Finance ledger", "By biller", "Financial event mix", "Recent financial events"]) {
expect(container.textContent).toContain(label);
}
expect(container.textContent).toContain("API, CLI, or a supported integration");
for (const tab of ["Providers", "Billers", "Overview", ...(name === "standalone" ? ["Budgets"] : [])]) {
await click(tab);
expect(container.textContent).not.toContain("Accounting health");
expect(container.textContent).not.toContain("Record or import charges");
expect(container.textContent).not.toContain("Open accounting tools");
}
if (name === "streamlined") await render({ initialTab: "budgets", lockTab: true });
await act(async () => { await vi.advanceTimersByTimeAsync(30_001); });
await settle();
expect(container.textContent).not.toContain("Accounting health");
expect(container.textContent).not.toContain("Record or import charges");
expect(fetch).not.toHaveBeenCalled();
expect(client.getQueryCache().getAll().some(query => query.queryKey[0] === "accounting")).toBe(false);
});
it("keeps loaded rows and expanded models through minute ticks, failed reloads, and recovery", async () => {
await render();
expect(container.textContent).toContain("runs: 72 api · 0 sub");
expect(container.textContent).toContain("200.0M cached");
const name = container.querySelector('[title="Bender"]') as HTMLElement;
expect(name).not.toBeNull();
await act(async () => name.click());
expect(container.textContent).toContain("model-breakdown-fixture");
const row = name.closest(".border");
const firstTo = api.summary.mock.calls[0][2];
api.summary.mockRejectedValue(new Error("private SQL error"));
api.financeEvents.mockRejectedValue(new Error("private finance error"));
// Flush React at the clock boundary before the next poll invokes its updated
// query function, just as separate browser timer tasks do.
await act(async () => { await vi.advanceTimersByTimeAsync(50_000); });
await settle();
await act(async () => { await vi.advanceTimersByTimeAsync(11_000); });
await settle();
expect(container.textContent).toContain("$402.88");
expect(container.textContent).toContain("model-breakdown-fixture");
expect(container.querySelector('[title="Bender"]')?.closest(".border")).toBe(row);
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).not.toContain("private");
expect(container.querySelectorAll(".animate-pulse")).toHaveLength(0);
expect(client.getQueryCache().getAll().filter((query) => query.queryKey[0] === "costs")).toHaveLength(2);
expect(api.summary.mock.calls.at(-1)![2]).not.toBe(firstTo);
api.summary.mockResolvedValue({ spendCents: 40300, budgetCents: 0, utilizationPercent: 0 });
api.financeEvents.mockResolvedValue([]);
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
await settle();
expect(container.textContent).toContain("$403.00");
expect(container.textContent).not.toContain("Showing the last loaded data");
});
it.each(["7 Days", "30 Days"])("retains all report cache entries for %s across midnight", async (period) => {
vi.setSystemTime(new Date(2026, 8, 29, 23, 59, 50));
await render(); await click(period);
await click("Providers"); await click("Billers"); await click("Overview");
const keys = client.getQueryCache().getAll().map(q => q.queryHash).sort();
const firstFrom = api.summary.mock.calls.at(-1)![1];
const row = container.querySelector('[title="Bender"]')!.closest(".border");
for (const method of ["summary", "financeEvents", "byProvider", "byBiller"]) api[method].mockRejectedValue(new Error("private failure"));
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); await settle();
await act(async () => { await vi.advanceTimersByTimeAsync(31_000); }); await settle();
expect(api.summary.mock.calls.at(-1)![1]).not.toBe(firstFrom);
expect(container.textContent).toContain("$402.88");
expect(container.querySelector('[title="Bender"]')?.closest(".border")).toBe(row);
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).not.toContain("private failure");
await click("Providers"); await click("Billers");
expect(client.getQueryCache().getAll().map(q => q.queryHash).sort()).toEqual(keys);
});
it.each([
["Month to Date", "2026-09-30T23:59:50Z"],
["Year to Date", "2026-12-31T23:59:50Z"],
])("starts a new %s report at its UTC boundary", async (period, boundary) => {
vi.setSystemTime(new Date(boundary));
await render(); await click(period);
expect(container.textContent).toContain("$402.88");
const previousFrom = api.summary.mock.calls.at(-1)![1];
api.summary.mockRejectedValue(new Error("private failure"));
api.financeEvents.mockRejectedValue(new Error("private failure"));
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); await settle();
expect(api.summary.mock.calls.at(-1)![1]).not.toBe(previousFrom);
expect(container.textContent).not.toContain("$402.88");
expect(container.textContent).not.toContain("Bender");
expect(container.textContent).toContain("Cost data could not be loaded");
expect(container.textContent).not.toContain("private failure");
});
it("does not reuse another company's or selected period's data after a failed initial load", async () => {
await render();
api.summary.mockRejectedValue(new Error("database detail must stay private"));
await click("7 Days");
expect(container.textContent).not.toContain("Bender");
expect(container.textContent).not.toContain("$402.88");
expect(container.textContent).toContain("Cost data could not be loaded");
expect(container.textContent).not.toContain("database detail");
await click("Month to Date");
expect(container.textContent).toContain("Bender");
state.companyId = "company-2";
await render();
expect(container.textContent).not.toContain("Bender");
expect(container.textContent).not.toContain("$402.88");
expect(api.summary.mock.calls.at(-1)![0]).toBe("company-2");
});
it("keeps inference spend visible when the finance ledger fails on first load", async () => {
api.financeEvents.mockRejectedValue(new Error("internal finance stack trace"));
await render();
expect(container.textContent).toContain("Bender");
expect(container.textContent).toContain("Financial events could not be loaded");
expect(container.textContent).not.toContain("internal finance");
expect(container.textContent).not.toContain("No financial events recorded");
expect(container.textContent).not.toContain("$0.00 debits");
expect(container.textContent).not.toContain("0 total events in range");
});
it("explains first-load budget failures on Overview and restores controls after polling succeeds", async () => {
overview.mockRejectedValue(new Error("internal budget stack trace"));
await render();
const message = "Budget data could not be loaded. Please try again shortly.";
const notices = () => [...container.querySelectorAll('[role="status"]')].filter(node => node.textContent === message);
expect(notices()).toHaveLength(1);
expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Overview");
expect(container.textContent).toContain("Bender");
expect(container.textContent).not.toContain("Showing the last loaded data");
expect(container.textContent).not.toContain("internal budget");
expect(container.textContent).not.toContain("Raise budget & resume");
await render({ initialTab: "budgets", lockTab: true });
expect(container.textContent?.split(message)).toHaveLength(2);
expect(container.textContent).not.toContain("Budget control plane");
await render();
overview.mockResolvedValue({
policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }],
pendingApprovalCount: 1, pausedAgentCount: 1, pausedProjectCount: 0,
});
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
await settle();
expect(notices()).toHaveLength(0);
expect(container.textContent).toContain("Raise budget & resume");
expect(container.textContent).toContain("Bender");
});
it.each(["summary", "financeEvents", "byProvider", "byBiller", "windowSpend"])("shows independent budget and stale-report notices when %s refresh fails", async (method) => {
overview.mockRejectedValue(new Error("internal budget failure"));
await render();
if (method === "byProvider" || method === "windowSpend") await click("Providers");
if (method === "byBiller") await click("Billers");
const successfulReport = api[method].getMockImplementation()!;
api[method].mockRejectedValue(new Error("internal report failure"));
await act(async () => { await client.invalidateQueries(); });
await settle();
expect(container.textContent).toContain("Budget data could not be loaded");
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).toContain("$402.88");
expect(container.textContent).not.toContain("internal");
overview.mockResolvedValue({ policies: [], activeIncidents: [], pendingApprovalCount: 0, pausedAgentCount: 0, pausedProjectCount: 0 });
await act(async () => { await client.invalidateQueries(); });
await settle();
expect(container.textContent).not.toContain("Budget data could not be loaded");
expect(container.textContent).toContain("Showing the last loaded data");
api[method].mockImplementation(successfulReport);
await act(async () => { await client.invalidateQueries(); });
await settle();
expect(container.textContent).not.toContain("Showing the last loaded data");
});
it("retains loaded budget controls and finance totals on background errors", async () => {
overview.mockResolvedValue({
policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }],
pendingApprovalCount: 1, pausedAgentCount: 1, pausedProjectCount: 0,
});
await render();
const resume = [...container.querySelectorAll("button")].find(node => node.textContent === "Raise budget & resume");
expect(resume).toBeDefined();
overview.mockRejectedValue(new Error("internal budget failure"));
api.financeEvents.mockRejectedValue(new Error("internal finance failure"));
await act(async () => { await client.invalidateQueries(); });
await settle();
expect([...container.querySelectorAll("button")].find(node => node.textContent === "Raise budget & resume")).toBe(resume);
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).not.toContain("Budget data could not be loaded");
if (name === "streamlined") {
await render({ initialTab: "budgets", lockTab: true });
expect(container.querySelector('[role="tab"]')).toBeNull();
expect(container.textContent).not.toContain("Inference spend");
} else {
await click("Budgets");
}
expect(container.textContent).toContain("Budget control plane");
expect(container.textContent).not.toContain("internal budget");
if (name === "streamlined") await render();
await click("Finance");
expect(container.textContent).toContain("Finance ledger");
expect(container.textContent).toContain("0 total events in range");
expect(container.textContent).not.toContain("internal finance");
});
it("retains each provider's successful quota on failure and clears it on company change", async () => {
api.byProvider.mockResolvedValue([{
provider: "openai", biller: "openai", billingType: "api_metered", model: "codex-fixture",
costCents: 0, inputTokens: 100, cachedInputTokens: 0, outputTokens: 10,
apiRunCount: 1, subscriptionRunCount: 0, subscriptionCachedInputTokens: 0,
subscriptionInputTokens: 0, subscriptionOutputTokens: 0,
}]);
api.quotaWindows.mockResolvedValue([{
provider: "openai", source: "codex-rpc", ok: true,
windows: [{ label: "5h limit", usedPercent: 37, resetsAt: null, valueLabel: null }],
}]);
await render();
await click("Providers");
expect(container.textContent).toContain("37% used");
api.quotaWindows.mockResolvedValue([{
provider: "openai", ok: false, error: "Command failed: codex -a untrusted; secret", windows: [],
}]);
await act(async () => { await client.invalidateQueries({ queryKey: ["usage-quota-windows", "company-1"] }); });
await settle();
expect(container.textContent).toContain("37% used");
expect(container.textContent).toContain("Showing the last available quota");
expect(container.textContent).not.toContain("Command failed");
api.quotaWindows.mockRejectedValue(new Error("gateway internal error"));
await act(async () => { await client.invalidateQueries({ queryKey: ["usage-quota-windows", "company-1"] }); });
await settle();
expect(container.textContent).toContain("37% used");
expect(container.textContent).not.toContain("gateway internal error");
api.quotaWindows.mockResolvedValue([{
provider: "openai", ok: false, error: "Command failed: codex", windows: [],
}]);
state.companyId = "company-2";
await render();
expect(container.textContent).not.toContain("37% used");
expect(container.textContent).toContain("Subscription quota is currently unavailable");
});
});
+22
View File
@@ -48,6 +48,8 @@ import type { issuesApi } from "../api/issues";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
getCostSummary: vi.fn(),
listApprovals: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
@@ -1326,6 +1328,8 @@ describe("IssueDetail", () => {
text: async () => "# Attachment preview",
} as Response);
mockIssuesApi.getCostSummary.mockResolvedValue(null);
mockIssuesApi.listApprovals.mockResolvedValue([]);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listAll.mockImplementation((...args) => mockIssuesApi.list(...args));
mockIssuesApi.listComments.mockResolvedValue([]);
@@ -1673,6 +1677,24 @@ describe("IssueDetail", () => {
});
});
it.each([false, true])("counts cached tokens once in direct and tree cost summaries (receipt: %s)", async (receipt) => {
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
mockLocation.hash = "#document-continuation-summary";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: false });
mockActivityApi.runsForIssue.mockResolvedValue([{
runId: "cost-run", agentId: "agent-1", status: "succeeded",
usageJson: { provider: "openai", inputTokens: receipt ? 20 : 100, cachedInputTokens: 80, outputTokens: 10, ...(receipt ? { accountingReceiptId: "receipt-1" } : {}) },
}]);
mockIssuesApi.getCostSummary.mockResolvedValue({ inputTokens: 20, cachedInputTokens: 80, outputTokens: 10, costCents: 0, issueCount: 2, runCount: 0, runtimeMs: 0 });
await act(async () => { root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>); });
await waitForAssertion(() => {
const direct = Array.from(container.querySelectorAll("span")).find(node => node.textContent === "This task")?.parentElement;
const tree = Array.from(container.querySelectorAll("span")).find(node => node.textContent?.startsWith("Including sub-tasks"))?.parentElement;
expect(direct?.textContent).toContain("Tokens 110");
expect(tree?.textContent).toContain("Tokens 110");
});
});
it("loads from the pending state into issue detail without changing hook order", async () => {
const issueRequest = createDeferred<Issue>();
mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise);
+11
View File
@@ -0,0 +1,11 @@
// Story fixtures and third-party components may call this secure-context-only
// API. Keep HTTP LAN/tailnet previews working with the same cryptographic bytes.
if (typeof globalThis.crypto.randomUUID !== "function") {
globalThis.crypto.randomUUID = () => {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
}
+5
View File
@@ -1,9 +1,11 @@
import path from "node:path";
import { hostname } from "node:os";
import { fileURLToPath } from "node:url";
import type { StorybookConfig } from "@storybook/react-vite";
import tailwindcss from "@tailwindcss/vite";
import { mergeConfig } from "vite";
import { storybookAgentAvatarAssets } from "../../../scripts/storybook-agent-avatar-assets.mjs";
import { storybookAllowedHosts } from "../allowed-hosts";
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
const paperclipInstanceOrigin = (() => {
@@ -16,6 +18,9 @@ const paperclipInstanceOrigin = (() => {
})();
const config: StorybookConfig = {
// Preserve LAN access without accepting DNS-rebinding hostnames. Storybook
// forwards this exact allowlist to Vite and websocket origin validation.
core: { allowedHosts: storybookAllowedHosts(hostname(), process.env.PAPERCLIP_STORYBOOK_ALLOWED_HOSTS) },
stories: ["../stories/**/*.stories.@(ts|tsx|mdx)"],
staticDirs: ["../../public", "../public"],
addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
+1
View File
@@ -1,3 +1,4 @@
import "./browser-compat";
import { useLayoutEffect, useState, type ReactNode } from "react";
import type { Preview } from "@storybook/react-vite";
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { storybookAllowedHosts } from "./allowed-hosts";
describe("Storybook hostname validation", () => {
it("allows the machine's LAN and mDNS names without opening arbitrary domains", () => {
expect(storybookAllowedHosts("Goldie")).toEqual(["localhost", "goldie", "goldie.local"]);
expect(storybookAllowedHosts("goldie.local")).toEqual(["localhost", "goldie.local", "goldie"]);
});
it("retains a machine's qualified name and exact additional aliases", () => {
expect(storybookAllowedHosts("goldie.office.example", " preview.tail123.ts.net, GOLDIE.LOCAL.\npreview.internal ")).toEqual([
"localhost", "goldie.office.example", "goldie", "goldie.local", "preview.tail123.ts.net", "preview.internal",
]);
});
it("deduplicates aliases and ignores empty separators", () => {
expect(storybookAllowedHosts("goldie", ",, goldie, localhost, ")).toEqual(["localhost", "goldie", "goldie.local"]);
});
it.each(["*", "*.example.com", ".example.com", ".local", "https://example.com", "example.com:6006", "example.com/path", "user@example.com", "example.com?query", "foo..bar", "-", "a".repeat(64), Array(5).fill("a".repeat(63)).join(".")])(
"rejects unsafe or malformed additional host %s", (host) => {
expect(() => storybookAllowedHosts("goldie", host)).toThrow("requires exact hostnames");
},
);
it.each(["", ".local", "*.local"])("never broadens the allowlist for an invalid machine name %s", (host) => {
expect(() => storybookAllowedHosts(host)).toThrow("requires exact hostnames");
});
});
+15
View File
@@ -0,0 +1,15 @@
/** Exact hostnames shared by Storybook's HTTP and websocket origin checks. */
export function storybookAllowedHosts(machineHostname: string, additionalHosts = ""): string[] {
const normalize = (value: string) => {
const host = value.trim().toLowerCase().replace(/\.$/, "");
const labels = host.split(".");
if (host.length > 253 || labels.some((label) => !/^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/.test(label))) {
throw new Error("PAPERCLIP_STORYBOOK_ALLOWED_HOSTS requires exact hostnames without schemes, ports, paths, or wildcards.");
}
return host;
};
const machine = normalize(machineHostname);
const shortName = machine.split(".")[0]!;
const extra = additionalHosts.split(/[,\s]+/).filter(Boolean).map(normalize);
return [...new Set(["localhost", machine, shortName, `${shortName}.local`, ...extra])];
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { CostByAgent, CostByAgentModel, CostByBiller, CostByProject, CostByProviderModel, CostByUserReport } from "@paperclipai/shared";
import { Costs } from "@/pages/Costs";
import { useCompany } from "@/context/CompanyContext";
const companyId = "company-storybook";
const base = {
agentStatus: "idle", inputTokens: 240_000, cachedInputTokens: 2_400_000,
outputTokens: 40_000, subscriptionRunCount: 0,
subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0,
};
const agents: CostByAgent[] = [
{ ...base, agentId: "bender", agentName: "Bender", costCents: 40288, inputTokens: 8_644_366, cachedInputTokens: 253_466_296, outputTokens: 2_008_119, apiRunCount: 72, eventCount: 72, estimatedEventCount: 0 },
{ ...base, agentId: "codie", agentName: "Codie", costCents: 1234, apiRunCount: 6, eventCount: 6, estimatedEventCount: 6 },
{ ...base, agentId: "fry", agentName: "Fry", costCents: 823, apiRunCount: 7, eventCount: 7, estimatedEventCount: 4 },
{ ...base, agentId: "leela", agentName: "Leela", costCents: 0, apiRunCount: 0, subscriptionRunCount: 11, eventCount: 11, estimatedEventCount: 0,
subscriptionInputTokens: base.inputTokens, subscriptionCachedInputTokens: base.cachedInputTokens, subscriptionOutputTokens: base.outputTokens },
];
const projects: CostByProject[] = [
{ projectId: "project-orchestration", projectName: "Agent orchestration", members: [agents[0]] },
{ projectId: "project-cost-reporting", projectName: "Cost reporting", members: [agents[1], agents[2]] },
{ projectId: "project-research", projectName: "Research", members: [agents[3]] },
].map(({ projectId, projectName, members }) => ({
projectId,
projectName,
costCents: members.reduce((sum, agent) => sum + agent.costCents, 0),
inputTokens: members.reduce((sum, agent) => sum + agent.inputTokens, 0),
cachedInputTokens: members.reduce((sum, agent) => sum + agent.cachedInputTokens, 0),
outputTokens: members.reduce((sum, agent) => sum + agent.outputTokens, 0),
}));
const users: CostByUserReport = {
activeUserCount: 2,
rows: [
{ userId: "alice", userName: "Alice", members: [agents[0], agents[3]] },
{ userId: "bob", userName: "Bob", members: [agents[1], agents[2]] },
].map(({ userId, userName, members }) => ({
userId, userName, userImage: null, unpricedEventCount: 0,
costCents: members.reduce((sum, agent) => sum + agent.costCents, 0),
costCentsExact: members.reduce((sum, agent) => sum + agent.costCents, 0).toFixed(7),
inputTokens: members.reduce((sum, agent) => sum + agent.inputTokens, 0),
cachedInputTokens: members.reduce((sum, agent) => sum + agent.cachedInputTokens, 0),
outputTokens: members.reduce((sum, agent) => sum + agent.outputTokens, 0),
runCount: members.reduce((sum, agent) => sum + agent.apiRunCount + agent.subscriptionRunCount, 0),
eventCount: members.reduce((sum, agent) => sum + agent.eventCount, 0),
estimatedEventCount: members.reduce((sum, agent) => sum + agent.estimatedEventCount, 0),
})),
};
const models: CostByAgentModel[] = [
{ ...agents[0], provider: "anthropic", biller: "anthropic", billingType: "metered_api", model: "claude-fable-5-1" },
{ ...agents[1], provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-astra" },
{ ...agents[2], provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-astra", costCents: 450, inputTokens: 140_000, cachedInputTokens: 1_400_000, outputTokens: 22_000, eventCount: 4, estimatedEventCount: 4 },
{ ...agents[2], provider: "openai", biller: "openai", billingType: "metered_api", model: "gpt-6-sol", costCents: 373, inputTokens: 100_000, cachedInputTokens: 1_000_000, outputTokens: 18_000, eventCount: 3, estimatedEventCount: 0 },
{ ...agents[3], provider: "anthropic", biller: "anthropic", billingType: "subscription_included", model: "claude-fable-5-1" },
];
const providerGroups = new Map<string, CostByProviderModel>();
for (const row of models) {
const key = JSON.stringify([row.provider, row.biller, row.billingType, row.model]);
const group = providerGroups.get(key) ?? {
provider: row.provider, biller: row.biller, billingType: row.billingType, model: row.model,
costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0,
apiRunCount: 0, subscriptionRunCount: 0,
subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0,
};
group.costCents += row.costCents;
group.inputTokens += row.inputTokens;
group.cachedInputTokens += row.cachedInputTokens;
group.outputTokens += row.outputTokens;
// Each fixture event represents one distinct run, including Fry's model split.
if (row.billingType === "subscription_included") {
group.subscriptionRunCount += row.eventCount;
group.subscriptionInputTokens += row.inputTokens;
group.subscriptionCachedInputTokens += row.cachedInputTokens;
group.subscriptionOutputTokens += row.outputTokens;
} else {
group.apiRunCount += row.eventCount;
}
providerGroups.set(key, group);
}
const providers = [...providerGroups.values()];
const billers: CostByBiller[] = [...new Set(providers.map(row => row.biller))].map(biller => {
const rows = providers.filter(row => row.biller === biller);
return {
biller,
costCents: rows.reduce((sum, row) => sum + row.costCents, 0),
inputTokens: rows.reduce((sum, row) => sum + row.inputTokens, 0),
cachedInputTokens: rows.reduce((sum, row) => sum + row.cachedInputTokens, 0),
outputTokens: rows.reduce((sum, row) => sum + row.outputTokens, 0),
apiRunCount: rows.reduce((sum, row) => sum + row.apiRunCount, 0),
subscriptionRunCount: rows.reduce((sum, row) => sum + row.subscriptionRunCount, 0),
subscriptionInputTokens: rows.reduce((sum, row) => sum + row.subscriptionInputTokens, 0),
subscriptionCachedInputTokens: rows.reduce((sum, row) => sum + row.subscriptionCachedInputTokens, 0),
subscriptionOutputTokens: rows.reduce((sum, row) => sum + row.subscriptionOutputTokens, 0),
providerCount: new Set(rows.map(row => row.provider)).size,
modelCount: new Set(rows.map(row => row.model)).size,
};
});
/** Render the actual Costs page. Fixtures stay inside this Storybook story. */
function CostsPreview() {
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const [ready, setReady] = useState(false);
useEffect(() => {
const previous = window.fetch;
window.fetch = async (input, init) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
const prefix = `/api/companies/${companyId}/`;
if (!url.pathname.startsWith(prefix)) return previous(input, init);
const resource = url.pathname.slice(prefix.length);
if (resource === "costs/finance-summary") return Response.json({ companyId, currency: "USD", debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, providerReportedCents: 0, eventCount: 0, currencies: [] });
if (resource === "costs/summary") return Response.json({ companyId, spendCents: 42345, budgetCents: 100000, utilizationPercent: 42.35, eventCount: 96, estimatedEventCount: 10, unpricedEventCount: 0, pendingRunCount: 0, pricingComplete: true });
if (resource === "costs/by-agent") return Response.json(agents);
if (resource === "costs/by-user") return Response.json(users);
if (resource === "costs/by-agent-model") return Response.json(models);
if (resource === "costs/by-project") return Response.json(projects);
if (resource === "costs/by-provider") return Response.json(providers);
if (resource === "costs/by-biller") return Response.json(billers);
if (resource.startsWith("costs/")) return Response.json([]);
if (resource === "budgets/overview") return Response.json({ companyId, policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0 });
if (resource.startsWith("accounting/") || resource === "finance-events") return Response.json({ error: "This action is not available in the preview." }, { status: 422 });
return previous(input, init);
};
setSelectedCompanyId(companyId);
setReady(true);
return () => { window.fetch = previous; };
}, [setSelectedCompanyId]);
if (!ready || selectedCompanyId !== companyId) return null;
return <main className="mx-auto max-w-6xl p-6"><Costs embedded initialTab="overview" hideBudgetsTab /></main>;
}
const meta = {
title: "Product/Costs",
component: CostsPreview,
parameters: {
layout: "fullscreen",
docs: { description: { component: "The shared Costs page as embedded in the default streamlined UI, with illustrative amounts. Codie has only estimates, Fry is partially estimated, Bender has only reported charges, and Leela uses a subscription. Expand an agent to inspect model labels. Run costs are attributed to Agent orchestration (Bender), Cost reporting (Codie and Fry), and Research (Leela); project costs and tokens reconcile with the agent totals. The existing Finance reports remain available. Charge entry, invoice import/reconciliation, and accounting tools are deferred from the UI." } },
},
} satisfies Meta<typeof CostsPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Overview: Story = {};