diff --git a/cli/src/__tests__/cost.test.ts b/cli/src/__tests__/cost.test.ts new file mode 100644 index 00000000000..13d2184dc56 --- /dev/null +++ b/cli/src/__tests__/cost.test.ts @@ -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(); + }); +}); diff --git a/cli/src/commands/client/cost.ts b/cli/src/commands/client/cost.ts index bd8c0ffed5e..4b51d9d1547 100644 --- a/cli/src/commands/client/cost.ts +++ b/cli/src/commands/client/cost.ts @@ -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("").option("-C, --company-id ", "Company ID") + .option("--payload-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 ", "Company ID"); + if (dateRange) { + command + .option("--from ", "Inclusive start date (default: UTC month start; unbounded with --to alone)") + .option("--to ", "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 ", "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); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index fede68c1399..f91e3315a2d 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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://: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. diff --git a/tests/e2e/cost-accounting.spec.ts b/tests/e2e/cost-accounting.spec.ts new file mode 100644 index 00000000000..84a1fccd6c1 --- /dev/null +++ b/tests/e2e/cost-accounting.spec.ts @@ -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 }); + } +}); diff --git a/ui/package.json b/ui/package.json index 8ded900de4a..dba4bd80b08 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/AccountQuotaPanels.test.tsx b/ui/src/components/AccountQuotaPanels.test.tsx new file mode 100644 index 00000000000..afe7435d4c9 --- /dev/null +++ b/ui/src/components/AccountQuotaPanels.test.tsx @@ -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(); + 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(); + 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"'); + }); +}); diff --git a/ui/src/components/BudgetPolicyCard.test.tsx b/ui/src/components/BudgetPolicyCard.test.tsx new file mode 100644 index 00000000000..505dcf86667 --- /dev/null +++ b/ui/src/components/BudgetPolicyCard.test.tsx @@ -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; + 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()); + 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('[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(); + }); +}); diff --git a/ui/src/components/ProviderQuotaCard.test.tsx b/ui/src/components/ProviderQuotaCard.test.tsx new file mode 100644 index 00000000000..5eebd6d1f12 --- /dev/null +++ b/ui/src/components/ProviderQuotaCard.test.tsx @@ -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(); + 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(); + 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(); + expect(html).toContain('>400 in'); + expect(html).toContain('>40 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(); + const biller = renderToStaticMarkup(); + 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(); + expect(html).toContain("20% of company budget"); + expect(html).not.toContain("80% of allocation"); + }); + +}); diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index a51d76423cf..bc553a583dc 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -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], diff --git a/ui/src/hooks/useDateRange.test.ts b/ui/src/hooks/useDateRange.test.ts new file mode 100644 index 00000000000..d320f8c3e4e --- /dev/null +++ b/ui/src/hooks/useDateRange.test.ts @@ -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(); + } +}); diff --git a/ui/src/lib/cost-reporting.test.ts b/ui/src/lib/cost-reporting.test.ts new file mode 100644 index 00000000000..3e16c4e7c48 --- /dev/null +++ b/ui/src/lib/cost-reporting.test.ts @@ -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); + }); + +}); diff --git a/ui/src/lib/quota-refresh.test.tsx b/ui/src/lib/quota-refresh.test.tsx new file mode 100644 index 00000000000..130cfacbe3b --- /dev/null +++ b/ui/src/lib/quota-refresh.test.tsx @@ -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(); + 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"); + } + }); +}); diff --git a/ui/src/pages/Costs.quota.test.tsx b/ui/src/pages/Costs.quota.test.tsx new file mode 100644 index 00000000000..77ea60479e8 --- /dev/null +++ b/ui/src/pages/Costs.quota.test.tsx @@ -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, 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()); + 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()); + await flush(); + const tab = [...container.querySelectorAll('[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()); + 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"); + }); +}); diff --git a/ui/src/pages/Costs.refresh.test.tsx b/ui/src/pages/Costs.refresh.test.tsx new file mode 100644 index 00000000000..f4e839de6a4 --- /dev/null +++ b/ui/src/pages/Costs.refresh.test.tsx @@ -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; + 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( + , + )); + 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('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"); + }); +}); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index af4a0d6cde1..1776d573767 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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(); }); + 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(); mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise); diff --git a/ui/storybook/.storybook/browser-compat.ts b/ui/storybook/.storybook/browser-compat.ts new file mode 100644 index 00000000000..bfde8c49ee2 --- /dev/null +++ b/ui/storybook/.storybook/browser-compat.ts @@ -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)}`; + }; +} diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index 7f8da855382..5b4930ac47e 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -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"], diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index 1c6fbaed032..820210c17c5 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -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"; diff --git a/ui/storybook/allowed-hosts.test.ts b/ui/storybook/allowed-hosts.test.ts new file mode 100644 index 00000000000..1c54198e3aa --- /dev/null +++ b/ui/storybook/allowed-hosts.test.ts @@ -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"); + }); +}); diff --git a/ui/storybook/allowed-hosts.ts b/ui/storybook/allowed-hosts.ts new file mode 100644 index 00000000000..93735ff397a --- /dev/null +++ b/ui/storybook/allowed-hosts.ts @@ -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])]; +} diff --git a/ui/storybook/stories/costs.stories.tsx b/ui/storybook/stories/costs.stories.tsx new file mode 100644 index 00000000000..1e4d2e70a85 --- /dev/null +++ b/ui/storybook/stories/costs.stories.tsx @@ -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(); +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
; +} + +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; +export default meta; +type Story = StoryObj; +export const Overview: Story = {};