From 5c2d425b02f37ba28bc7f66bd87dea3a3b0ea303 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 3 Oct 2026 21:43:36 -0700 Subject: [PATCH] fix: retain uncertain corrections and cached cost reports Co-Authored-By: Paperclip --- .../components/AccountingHealthPanel.test.tsx | 37 ++++++++++++++++++ ui/src/components/AccountingHealthPanel.tsx | 39 +++++++++++++++---- ui/src/pages/Costs.refresh.test.tsx | 19 +++++++++ ui/src/pages/Costs.test.tsx | 22 ++++++++++- ui/src/pages/Costs.tsx | 18 +++++---- 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/ui/src/components/AccountingHealthPanel.test.tsx b/ui/src/components/AccountingHealthPanel.test.tsx index 4d46bf33e1e..b2bc5471127 100644 --- a/ui/src/components/AccountingHealthPanel.test.tsx +++ b/ui/src/components/AccountingHealthPanel.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../api/client"; import { AccountingHealthPanel } from "./AccountingHealthPanel"; const api = vi.hoisted(() => ({ health: vi.fn(), inspect: vi.fn(), repair: vi.fn(), retry: vi.fn(), invoices: vi.fn(), importInvoice: vi.fn(), reconcile: vi.fn(), adjust: vi.fn() })); vi.mock("../api/accounting", () => ({ accountingApi: api })); @@ -68,6 +69,42 @@ describe("operator accounting tools", () => { expect(api.adjust).not.toHaveBeenCalled(); }); + it("confirms an uncertain correction with the original payload after reconciliation changes", async () => { + api.invoices.mockResolvedValue([{ id: "first", biller: "openai", externalId: "invoice-one" }, { id: "second", biller: "openai", externalId: "invoice-two" }]); + api.reconcile.mockResolvedValue({ invoice: { externalId: "invoice-one", currency: "USD" }, lines: [{ id: "line", externalId: "charge", status: "difference", matchedEventId: "event", recordedCents: "1", amountCents: "2", differenceCents: "1" }] }); + api.adjust.mockRejectedValueOnce(new Error("Response lost")).mockResolvedValue({}); + await render(); await click("Open accounting tools"); await click("openai · invoice-one"); + await input("Invoice correction reason", "Provider receipt"); await click("Apply reviewed correction"); + const original = structuredClone(api.adjust.mock.calls[0]); + expect(original).toEqual(["one", "event", { idempotencyKey: "invoice-line:line", invoiceLineId: "line", expectedCents: "1", correctedCents: "2", reason: "Provider receipt", pricing: { source: "provider_invoice", evidence: "invoice-one" } }]); + expect(container.querySelector('[aria-label="Invoice correction reason"]')!.disabled).toBe(true); + expect(button("openai · invoice-two").disabled).toBe(true); + expect(button("Apply reviewed correction").disabled).toBe(true); + // A focus refresh may already show the saved adjustment. Confirmation must + // still be available and must not derive its payload from these new totals. + api.reconcile.mockResolvedValue({ invoice: { externalId: "invoice-one", currency: "USD" }, lines: [] }); + await act(async () => { await client.invalidateQueries(); }); await flush(); + await click("Hide accounting tools"); await click("Open accounting tools"); + await click("Confirm original correction"); + expect(api.adjust.mock.calls[1]).toEqual(original); + expect(container.textContent).toContain("Correction recorded"); + expect(container.textContent).not.toContain("Confirm original correction"); + expect(button("openai · invoice-two").disabled).toBe(false); + }); + + it("refreshes rejected corrections so the operator can review current evidence", async () => { + api.invoices.mockResolvedValue([{ id: "first", biller: "openai", externalId: "invoice-one" }]); + api.reconcile.mockResolvedValue({ invoice: { externalId: "invoice-one", currency: "USD" }, lines: [{ id: "line", externalId: "charge", status: "difference", matchedEventId: "event", recordedCents: "1", amountCents: "2", differenceCents: "1" }] }); + api.adjust.mockRejectedValue(new ApiError("Evidence changed", 409, {})); + await render(); await click("Open accounting tools"); await click("openai · invoice-one"); + await input("Invoice correction reason", "Provider receipt"); + const reads = api.reconcile.mock.calls.length; + await click("Apply reviewed correction"); + expect(api.reconcile.mock.calls.length).toBeGreaterThan(reads); + expect(container.querySelector('[aria-label="Invoice correction reason"]')!.disabled).toBe(false); + expect(container.textContent).not.toContain("Confirm original correction"); + }); + it("retries only ready receipts and keeps incomplete evidence visible", async () => { api.health.mockResolvedValue({ companyId: "one", pendingRunCount: 2, unpricedEventCount: 0, pendingCancellationCount: 0, heldReservationCents: "0", items: [ { runId: "ready", agentId: "a", state: "retryable", attempts: 2, since: "2026-09-28", lastError: "Database unavailable" }, diff --git a/ui/src/components/AccountingHealthPanel.tsx b/ui/src/components/AccountingHealthPanel.tsx index 6917c6babc0..436efea39f8 100644 --- a/ui/src/components/AccountingHealthPanel.tsx +++ b/ui/src/components/AccountingHealthPanel.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { formatUsdExact, importBillingInvoiceSchema, type AccountingInspection } from "@paperclipai/shared"; import { accountingApi } from "../api/accounting"; +import { ApiError } from "../api/client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -18,6 +19,7 @@ function AccountingPanel({ companyId }: { companyId: string }) { const [inspection, setInspection] = useState(null); const [repairReason, setRepairReason] = useState(""); const [correctionReason, setCorrectionReason] = useState(""); + const [submittedCorrection, setSubmittedCorrection] = useState<{ eventId: string; input: Parameters[2] } | null>(null); const [invoiceJson, setInvoiceJson] = useState(""); const [invoiceId, setInvoiceId] = useState(""); const [notice, setNotice] = useState(""); @@ -27,6 +29,23 @@ function AccountingPanel({ companyId }: { companyId: string }) { const action = useMutation({ mutationFn: async (work: () => Promise) => { setNotice(""); await work(); }, onSuccess: async () => { await client.invalidateQueries(); } }); const run = (work: () => Promise) => action.mutate(work); + const correct = async (submission: NonNullable) => { + setSubmittedCorrection(submission); + try { + await accountingApi.adjust(companyId, submission.eventId, submission.input); + } catch (error) { + // A definite rejection can be reviewed again. An uncertain outcome must + // replay the original request, even if reconciliation has since changed. + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + setSubmittedCorrection(null); + await client.invalidateQueries({ queryKey: ["accounting", companyId, "invoice", invoiceId] }); + } + throw error; + } + setSubmittedCorrection(null); + setCorrectionReason(""); + setNotice("Correction recorded. The original provider charge remains in the audit history."); + }; const error = action.error ?? health.error ?? invoices.error ?? report.error; return @@ -74,22 +93,26 @@ function AccountingPanel({ companyId }: { companyId: string }) {

Provider invoices

Import normalized invoice JSON with biller, externalId, currency, and lines. Each line needs externalId, amountCents, occurredAt, and a costEventId, runId, or providerRequestId for matching. Decimal amounts are strings in cents. Fees and credits remain separate from inference charges.