From 8f6fa374c9be600bc134d9dcefce1311a5637c53 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 4 Oct 2026 03:20:38 -0700 Subject: [PATCH] fix: present safe accounting and budget errors Co-Authored-By: Paperclip --- doc/COST-ACCOUNTING.md | 2 + .../components/AccountingHealthPanel.test.tsx | 46 +++++++++++++++++-- ui/src/components/AccountingHealthPanel.tsx | 13 ++++-- ui/src/pages/Costs.test.tsx | 22 +++++++++ ui/src/pages/Costs.tsx | 2 +- 5 files changed, 77 insertions(+), 8 deletions(-) diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index 976cb684149..0f7275421a6 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -160,6 +160,8 @@ Company quota requests resolve the caller's accessible subscription AI connectio Responses carry an opaque account identity and successful capture timestamp. The 30-second cache is scoped to company, user, connection, grant and credential revision; authorization and revision metadata are checked before reading cached results. Transient failures retain the last successful UI observation with its original timestamp. Rotation, revocation and explicit authentication rejection clear obsolete windows. Public messages never contain provider subprocess commands or raw diagnostics. +Accounting tools and budget-policy saves also use safe error messages. Health and invoice refresh failures retain loaded values with a small notice; action failures explain conflicts or missing permission without displaying raw exceptions or stored recovery diagnostics. + Both Costs page variants retain loaded reports and expanded rows during background refresh failures, with a small stale-data notice. Initial failures remain visible. An initial budget-load failure shows one safe notice above the tabs, including when Budgets is hidden; successful polling restores incident controls automatically. Budget-load and stale-report notices remain independent when requests fail together. Company/date changes isolate cached reports. The run-count label is `runs: 0 api · 11 sub`: distinct recorded runs, not tasks or individual model requests. Input totals include cache reads, with the cached portion shown explicitly. Receipt identity and supplemental charges: `heartbeat:` idempotency keys are reserved for internally generated run receipts. Additional API-reported charges may link to the same run with their own keys. They contribute to monthly and acknowledged-run lifetime totals, while receipt-integrity checks compare only the original provider receipt. Recovery isolates each budget scope so a deleted target or failed scope does not prevent recovery elsewhere. diff --git a/ui/src/components/AccountingHealthPanel.test.tsx b/ui/src/components/AccountingHealthPanel.test.tsx index b2bc5471127..144d439da48 100644 --- a/ui/src/components/AccountingHealthPanel.test.tsx +++ b/ui/src/components/AccountingHealthPanel.test.tsx @@ -35,22 +35,58 @@ describe("operator accounting tools", () => { expect(container.textContent).toContain("Oldest pending:"); expect(api.invoices).not.toHaveBeenCalled(); }); it("shows request failures instead of a healthy zero", async () => { - api.health.mockRejectedValue(new Error("Accounting service unavailable")); await render(); - expect(container.querySelector('[role="alert"]')?.textContent).toContain("Accounting service unavailable"); + api.health.mockRejectedValue(new Error("private SQL connection string")); await render(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Accounting health could not be loaded"); + expect(container.textContent).not.toContain("private SQL"); expect(container.textContent).not.toContain("0 pending runs"); }); + it("retains health during failed refreshes and clears the safe notice after recovery", async () => { + await render(); + api.health.mockRejectedValue(new Error("private health failure")); + await act(async () => { await client.invalidateQueries(); }); await flush(); + expect(container.textContent).toContain("1 pending runs"); + expect(container.textContent).toContain("Showing the last loaded accounting health"); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(container.textContent).not.toContain("private health"); + api.health.mockResolvedValue({ pendingRunCount: 0, unpricedEventCount: 0, pendingCancellationCount: 0, heldReservationCents: "0", items: [] }); + await act(async () => { await client.invalidateQueries(); }); await flush(); + expect(container.textContent).toContain("0 pending runs"); + expect(container.textContent).not.toContain("Showing the last loaded accounting health"); + }); + it("keeps invoice, comparison and action failures separate without exposing diagnostics", async () => { + api.invoices.mockResolvedValue([{ id: "first", biller: "openai", externalId: "invoice-one" }]); + api.reconcile.mockRejectedValue(new Error("private comparison failure")); + api.inspect.mockRejectedValue(new Error("private action failure")); + await render(); await click("Open accounting tools"); await click("openai · invoice-one"); + expect(container.textContent).toContain("The invoice comparison could not be loaded"); + api.invoices.mockRejectedValue(new Error("private invoices failure")); + await act(async () => { await client.invalidateQueries(); }); await flush(); + await click("Inspect stored totals"); + expect(container.textContent).toContain("Showing the last loaded invoices"); + expect(container.textContent).toContain("The invoice comparison could not be loaded"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not complete the accounting action"); + expect(container.textContent).toContain("openai · invoice-one"); + expect(container.textContent).not.toContain("private"); + }); it("requires a reason and the reviewed fingerprint, and surfaces stale repairs", async () => { api.inspect.mockResolvedValue({ companyId: "one", fingerprint: "a".repeat(64), checkedAt: "2026-09-28", findings: [{ kind: "company_projection", entityId: "one", repairable: true, actual: { cents: "99" }, expected: { cents: "12.5" } }] }); - api.repair.mockRejectedValue(new Error("Accounting changed since inspection; inspect again before repairing")); + api.repair.mockRejectedValue(new ApiError("private conflict details", 409, {})); await render(); await click("Open accounting tools"); await click("Inspect stored totals"); expect(button("Repair reviewed totals").disabled).toBe(true); await input("Accounting repair reason", "Rebuild known drift"); await click("Repair reviewed totals"); expect(api.repair).toHaveBeenCalledWith("one", "a".repeat(64), "Rebuild known drift"); - expect(container.querySelector('[role="alert"]')?.textContent).toContain("changed since inspection"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Accounting changed"); + expect(container.textContent).not.toContain("private conflict"); await render("two"); expect(container.textContent).not.toContain("Repair reviewed totals"); expect(api.health).toHaveBeenLastCalledWith("two"); }); + it.each([401, 403])("explains accounting permission failures (%s) without server details", async (status) => { + api.inspect.mockRejectedValue(new ApiError("private authorization details", status, {})); + await render(); await click("Open accounting tools"); await click("Inspect stored totals"); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("You do not have permission"); + expect(container.textContent).not.toContain("private authorization"); + }); it("keeps repair and correction reasons separate and clears corrections when changing invoices", async () => { api.inspect.mockResolvedValue({ companyId: "one", fingerprint: "a".repeat(64), findings: [{ kind: "company_projection", entityId: "one", repairable: true, actual: { cents: "99" }, expected: { cents: "12.5" } }] }); api.invoices.mockResolvedValue([{ id: "first", biller: "openai", externalId: "invoice-one" }, { id: "second", biller: "openai", externalId: "invoice-two" }]); @@ -113,6 +149,8 @@ describe("operator accounting tools", () => { await render(); await click("Open accounting tools"); expect(Array.from(container.querySelectorAll("button")).filter(b => b.textContent === "Retry accounting")).toHaveLength(1); expect(container.textContent).toContain("Waiting for provider evidence"); + expect(container.textContent).toContain("The last accounting attempt failed"); + expect(container.textContent).not.toContain("Database unavailable"); await click("Retry accounting"); expect(api.retry).toHaveBeenCalledWith("one", "ready"); }); }); diff --git a/ui/src/components/AccountingHealthPanel.tsx b/ui/src/components/AccountingHealthPanel.tsx index 436efea39f8..116c513b736 100644 --- a/ui/src/components/AccountingHealthPanel.tsx +++ b/ui/src/components/AccountingHealthPanel.tsx @@ -46,14 +46,21 @@ function AccountingPanel({ companyId }: { companyId: string }) { setCorrectionReason(""); setNotice("Correction recorded. The original provider charge remains in the audit history."); }; - const error = action.error ?? health.error ?? invoices.error ?? report.error; + const actionError = action.error instanceof ApiError && action.error.status === 409 + ? "Accounting changed or this request conflicts with an existing record. Inspect current totals or review the invoice before trying again." + : action.error instanceof ApiError && [401, 403].includes(action.error.status) + ? "You do not have permission to complete this accounting action." + : "Could not complete the accounting action. Review the inputs and current accounting state, then try again."; return Accounting health Receipt recovery, budget capacity, and provider invoice reconciliation. - {error &&

{error instanceof Error ? error.message : "Accounting request failed"}

} + {action.error &&

{actionError}

} + {health.error &&

{health.data ? "Showing the last loaded accounting health. Updates will resume automatically." : "Accounting health could not be loaded. Please try again shortly."}

} + {expanded && invoices.error &&

{invoices.data ? "Showing the last loaded invoices. Please try again shortly." : "Invoices could not be loaded. Please try again shortly."}

} + {expanded && report.error &&

{report.data ? "The invoice comparison could not be refreshed. Previously loaded values are shown." : "The invoice comparison could not be loaded. Please try again shortly."}

} {notice &&

{notice}

} {health.isPending &&

Loading accounting health…

} {health.data &&
@@ -66,7 +73,7 @@ function AccountingPanel({ companyId }: { companyId: string }) { {health.data?.items.map(item =>

{item.state.replaceAll("_", " ")} · {item.runId ?? item.costEventId}

Since {new Date(item.since).toLocaleString()} · {item.attempts} recovery attempts

- {item.lastError &&

{item.lastError}

} + {item.lastError &&

The last accounting attempt failed. Retry accounting or inspect stored totals.

} {item.state === "waiting_for_receipt" &&

Waiting for provider evidence. Recovery will not invent a zero charge.

} {item.state === "unpriced" &&

Import a provider invoice below to review and price this charge.

} {item.state === "retryable" && item.runId &&