fix: retain uncertain corrections and cached cost reports

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-03 21:43:36 -07:00
1 parent 6af7db0166
commit 5c2d425b02
5 files changed
+118 -17

No files matched your search

@@ -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<HTMLInputElement>('[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<HTMLInputElement>('[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" },
+31 -8
View File
@@ -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<AccountingInspection | null>(null);
const [repairReason, setRepairReason] = useState("");
const [correctionReason, setCorrectionReason] = useState("");
const [submittedCorrection, setSubmittedCorrection] = useState<{ eventId: string; input: Parameters<typeof accountingApi.adjust>[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<void>) => { setNotice(""); await work(); },
onSuccess: async () => { await client.invalidateQueries(); } });
const run = (work: () => Promise<void>) => action.mutate(work);
const correct = async (submission: NonNullable<typeof submittedCorrection>) => {
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 <Card>
<CardHeader>
@@ -74,22 +93,26 @@ function AccountingPanel({ companyId }: { companyId: string }) {
<p className="font-medium">Provider invoices</p>
<p className="text-sm text-muted-foreground">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.</p>
<Textarea aria-label="Invoice JSON" value={invoiceJson} onChange={e => setInvoiceJson(e.target.value)} placeholder='{"biller":"anthropic","externalId":"invoice-123","currency":"USD","lines":[]}' />
<Button disabled={action.isPending || !invoiceJson.trim()} onClick={() => run(async () => {
<Button disabled={action.isPending || !!submittedCorrection || !invoiceJson.trim()} onClick={() => run(async () => {
const parsed = importBillingInvoiceSchema.parse(JSON.parse(invoiceJson));
const invoice = await accountingApi.importInvoice(companyId, parsed); setInvoiceId(invoice.id); setCorrectionReason(""); setInvoiceJson(""); setNotice("Invoice imported. Review differences before applying a correction.");
})}>Import invoice for review</Button>
<div className="flex flex-wrap gap-2">{invoices.data?.map(invoice => <Button variant="outline" key={invoice.id} onClick={() => { setInvoiceId(invoice.id); setCorrectionReason(""); }}>{invoice.biller} · {invoice.externalId}</Button>)}</div>
<div className="flex flex-wrap gap-2">{invoices.data?.map(invoice => <Button variant="outline" key={invoice.id} disabled={action.isPending || !!submittedCorrection} onClick={() => { setInvoiceId(invoice.id); setCorrectionReason(""); }}>{invoice.biller} · {invoice.externalId}</Button>)}</div>
{submittedCorrection && <div className="space-y-2">
<p role="status" className="text-sm">Confirm the original correction before changing it. Retrying will not create another charge.</p>
<Button variant="outline" disabled={action.isPending} onClick={() => run(() => correct(submittedCorrection))}>Confirm original correction</Button>
</div>}
{report.data && <>
<Input aria-label="Invoice correction reason" placeholder="Reason for applying a correction" value={correctionReason} onChange={e => setCorrectionReason(e.target.value)} />
<Input aria-label="Invoice correction reason" placeholder="Reason for applying a correction" disabled={!!submittedCorrection} value={correctionReason} onChange={e => setCorrectionReason(e.target.value)} />
{report.data.lines.map(line => <div key={line.id} className="space-y-2 border-t pt-3 text-sm">
<p>{line.externalId} · {line.status.replaceAll("_", " ")} · {line.amountCents} {report.data!.invoice.currency} cents</p>
{line.recordedCents !== null && <p>Recorded: {line.recordedCents} cents · Difference: {line.differenceCents} cents</p>}
{line.status === "difference" && line.matchedEventId && line.recordedCents !== null && <Button variant="outline" disabled={action.isPending || !correctionReason.trim()} onClick={() => run(async () => {
await accountingApi.adjust(companyId, line.matchedEventId!, { idempotencyKey: `invoice-line:${line.id}`, invoiceLineId: line.id,
{line.status === "difference" && line.matchedEventId && line.recordedCents !== null && <Button variant="outline" disabled={action.isPending || !!submittedCorrection || !correctionReason.trim()} onClick={() => run(() => correct({
eventId: line.matchedEventId!,
input: { idempotencyKey: `invoice-line:${line.id}`, invoiceLineId: line.id,
expectedCents: line.recordedCents!, correctedCents: line.amountCents, reason: correctionReason,
pricing: { source: "provider_invoice", evidence: report.data!.invoice.externalId } });
setNotice("Correction recorded. The original provider charge remains in the audit history.");
})}>Apply reviewed correction</Button>}
pricing: { source: "provider_invoice", evidence: report.data!.invoice.externalId } },
}))}>Apply reviewed correction</Button>}
</div>)}
</>}
</div>
+19
View File
@@ -116,6 +116,25 @@ describe.each([
expect(container.textContent).not.toContain("Showing the last loaded data");
});
it.each(["Last 7 Days", "Last 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("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"));
+21 -1
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Costs } from "./Costs";
import { MemoryRouter } from "react-router-dom";
const resolveIncidentMock = vi.hoisted(() => vi.fn());
const upsertPolicyMock = vi.hoisted(() => vi.fn());
const budgetOverviewMock = vi.hoisted(() => vi.fn());
const setBreadcrumbsMock = vi.hoisted(() => vi.fn());
@@ -30,7 +31,7 @@ vi.mock("../api/budgets", () => ({
budgetsApi: {
overview: (...args: unknown[]) => budgetOverviewMock(...args),
upsertPolicy: upsertPolicyMock,
resolveIncident: vi.fn(),
resolveIncident: resolveIncidentMock,
},
}));
@@ -128,6 +129,25 @@ describe("Shared Costs surfaces", () => {
queryClient.clear();
});
it.each(surfaces)("shows failed budget actions on the %s Overview", async (_name, props) => {
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 1000, pricingComplete: false });
costsApiMocks.financeSummary.mockResolvedValue({ netCents: 0, debitCents: 0, creditCents: 0, estimatedDebitCents: 0, eventCount: 0 });
budgetOverviewMock.mockResolvedValue({ policies: [], activeIncidents: [{ id: "incident", scopeType: "agent", scopeName: "Codie", status: "open", thresholdType: "hard", amountObserved: 200, amountLimit: 1000 }], pausedAgentCount: 1, pausedProjectCount: 0 });
resolveIncidentMock.mockRejectedValue(new Error("private database error"));
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
root = createRoot(container);
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} /></QueryClientProvider></MemoryRouter>));
await act(async () => { await vi.waitFor(() => expect(container.textContent).toContain("Raise budget & resume")); });
await act(async () => [...container.querySelectorAll("button")].find(b => b.textContent === "Raise budget & resume")!.click());
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not update the budget")); });
expect(resolveIncidentMock).toHaveBeenCalledWith("company-1", "incident", { incidentId: "incident", action: "raise_budget_and_resume", amount: 1200 });
expect(container.querySelector('[role="tab"][data-state="active"]')?.textContent).toBe("Overview");
expect(container.textContent).toContain("Review pending accounting and unpriced usage");
expect(container.textContent).not.toContain("private database error");
queryClient.clear();
});
it("compares monthly budgets only with month-to-date spend", async () => {
for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]);
costsApiMocks.summary.mockResolvedValue({ spendCents: 200, budgetCents: 1000, utilizationPercent: 20, pricingComplete: true });
+10 -8
View File
@@ -219,6 +219,7 @@ export function Costs({
// The clock advances the request bounds, not the identity of the report.
// Keep successful data visible during polling; a different company or selected
// period still gets its own cache entry and initial loading state.
const reportQueryFrom = preset === "custom" ? from || undefined : undefined;
const reportQueryTo = preset === "custom" ? to || undefined : preset;
const { data: budgetData, isLoading: budgetLoading, error: budgetError } = useQuery({
@@ -258,7 +259,7 @@ export function Costs({
});
const { data: spendData, isLoading: spendLoading, error: spendError } = useQuery({
queryKey: queryKeys.costs(companyId, from || undefined, reportQueryTo),
queryKey: queryKeys.costs(companyId, reportQueryFrom, reportQueryTo),
queryFn: async () => {
const [summary, byAgent, byProject, byAgentModel] = await Promise.all([
costsApi.summary(companyId, from || undefined, to || undefined),
@@ -274,10 +275,10 @@ export function Costs({
const { data: financeData, isLoading: financeLoading, error: financeError } = useQuery({
queryKey: [
queryKeys.financeSummary(companyId, from || undefined, reportQueryTo),
queryKeys.financeByBiller(companyId, from || undefined, reportQueryTo),
queryKeys.financeByKind(companyId, from || undefined, reportQueryTo),
queryKeys.financeEvents(companyId, from || undefined, reportQueryTo, 18),
queryKeys.financeSummary(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeByBiller(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeByKind(companyId, reportQueryFrom, reportQueryTo),
queryKeys.financeEvents(companyId, reportQueryFrom, reportQueryTo, 18),
],
queryFn: async () => {
const [summary, byBiller, byKind, events] = await Promise.all([
@@ -320,7 +321,7 @@ export function Costs({
}, [spendData?.byAgentModel]);
const { data: providerData } = useQuery({
queryKey: queryKeys.usageByProvider(companyId, from || undefined, reportQueryTo),
queryKey: queryKeys.usageByProvider(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && (mainTab === "providers" || mainTab === "billers"),
refetchInterval: 30_000,
@@ -328,7 +329,7 @@ export function Costs({
});
const { data: billerData } = useQuery({
queryKey: queryKeys.usageByBiller(companyId, from || undefined, reportQueryTo),
queryKey: queryKeys.usageByBiller(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byBiller(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && mainTab === "billers",
refetchInterval: 30_000,
@@ -679,6 +680,7 @@ export function Costs({
) : null}
{!!spendData?.summary.estimatedEventCount && <p role="status" className="text-sm text-muted-foreground">Includes {spendData.summary.estimatedEventCount} estimated run charges. Token estimates use published rates and recorded assumptions; provider bills may differ.</p>}
{incidentMutation.error && <p role="alert" className="text-sm text-destructive">Could not update the budget. Review pending accounting and unpriced usage in Accounting tools, then try again.</p>}
<Tabs value={mainTab} onValueChange={(value) => setMainTab(value as typeof mainTab)}>
{!lockTab ? (
<TabsList variant="line" className="justify-start">
@@ -909,7 +911,7 @@ export function Costs({
</TabsContent>
<TabsContent value="budgets" className="mt-4 space-y-4">
{(policyMutation.error || incidentMutation.error) && <p role="alert" className="text-sm text-destructive">{(policyMutation.error ?? incidentMutation.error)?.message}</p>}
{policyMutation.error && <p role="alert" className="text-sm text-destructive">{policyMutation.error.message}</p>}
{budgetLoading ? (
<PageSkeleton variant="costs" />
) : budgetError && !budgetData ? (