mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
feat: add accounting CLI workflows and dashboard previews
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
799e4d556f
commit
c1102f05d4
21 files changed
+1365
-13
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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])];
|
||||
}
|
||||
@@ -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 = {};
|
||||
Reference in new issue
Block a user