mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
fix: present safe accounting and budget errors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
dcc6c1bd31
commit
8f6fa374c9
5 files changed
+77
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accounting health</CardTitle>
|
||||
<CardDescription>Receipt recovery, budget capacity, and provider invoice reconciliation.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error instanceof Error ? error.message : "Accounting request failed"}</p>}
|
||||
{action.error && <p role="alert" className="text-sm text-destructive">{actionError}</p>}
|
||||
{health.error && <p role="status" className="text-sm text-muted-foreground">{health.data ? "Showing the last loaded accounting health. Updates will resume automatically." : "Accounting health could not be loaded. Please try again shortly."}</p>}
|
||||
{expanded && invoices.error && <p role="status" className="text-sm text-muted-foreground">{invoices.data ? "Showing the last loaded invoices. Please try again shortly." : "Invoices could not be loaded. Please try again shortly."}</p>}
|
||||
{expanded && report.error && <p role="status" className="text-sm text-muted-foreground">{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."}</p>}
|
||||
{notice && <p role="status" className="text-sm">{notice}</p>}
|
||||
{health.isPending && <p className="text-sm text-muted-foreground">Loading accounting health…</p>}
|
||||
{health.data && <div className="flex flex-wrap gap-4 text-sm">
|
||||
@@ -66,7 +73,7 @@ function AccountingPanel({ companyId }: { companyId: string }) {
|
||||
{health.data?.items.map(item => <div key={item.costEventId ?? item.runId} className="space-y-2 border-t pt-3 text-sm">
|
||||
<p>{item.state.replaceAll("_", " ")} · {item.runId ?? item.costEventId}</p>
|
||||
<p className="text-muted-foreground">Since {new Date(item.since).toLocaleString()} · {item.attempts} recovery attempts</p>
|
||||
{item.lastError && <p>{item.lastError}</p>}
|
||||
{item.lastError && <p>The last accounting attempt failed. Retry accounting or inspect stored totals.</p>}
|
||||
{item.state === "waiting_for_receipt" && <p>Waiting for provider evidence. Recovery will not invent a zero charge.</p>}
|
||||
{item.state === "unpriced" && <p>Import a provider invoice below to review and price this charge.</p>}
|
||||
{item.state === "retryable" && item.runId && <Button variant="outline" disabled={action.isPending} onClick={() => run(async () => {
|
||||
|
||||
@@ -131,6 +131,28 @@ describe("Shared Costs surfaces", () => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("shows a safe policy-save failure without losing the operator's settings", async () => {
|
||||
const policy = {
|
||||
policyId: "policy", companyId: "company-1", scopeType: "agent", scopeId: "agent", scopeName: "Worker", metric: "billed_cents", windowKind: "lifetime",
|
||||
amount: 500, warnPercent: 65, hardStopEnabled: false, notifyEnabled: false, isActive: false, unpricedUsagePolicy: "allow", reservationCents: "2.0000000",
|
||||
observedAmount: 0, remainingAmount: 500, utilizationPercent: 0, unpricedEventCount: 0, pendingRunCount: 0, status: "ok", paused: false, pauseReason: null,
|
||||
};
|
||||
budgetOverviewMock.mockResolvedValue({ policies: [policy], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0 });
|
||||
upsertPolicyMock.mockRejectedValueOnce(new Error("private SQL policy error"));
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<QueryClientProvider client={queryClient}><Costs embedded initialTab="budgets" lockTab /></QueryClientProvider>));
|
||||
await act(async () => { await vi.waitFor(() => expect(container.querySelector('input[type="checkbox"]')).not.toBeNull()); });
|
||||
const reservation = container.querySelector<HTMLInputElement>('[aria-label="Reserve per run (USD)"]')!;
|
||||
const originalReservation = reservation.value;
|
||||
await act(async () => container.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click());
|
||||
await act(async () => { await vi.waitFor(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Could not update the budget policy")); });
|
||||
expect(container.textContent).toContain("Worker");
|
||||
expect(container.textContent).not.toContain("private SQL");
|
||||
expect(reservation.value).toBe(originalReservation);
|
||||
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 });
|
||||
|
||||
@@ -913,7 +913,7 @@ export function Costs({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="budgets" className="mt-4 space-y-4">
|
||||
{policyMutation.error && <p role="alert" className="text-sm text-destructive">{policyMutation.error.message}</p>}
|
||||
{policyMutation.error && <p role="alert" className="text-sm text-destructive">Could not update the budget policy. Review your settings and try again.</p>}
|
||||
{budgetLoading ? (
|
||||
<PageSkeleton variant="costs" />
|
||||
) : budgetError && !budgetData ? null : (
|
||||
|
||||
Reference in new issue
Block a user