mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix: retain uncertain corrections and cached cost reports
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user