diff --git a/ui/src/components/BudgetPolicyCard.test.tsx b/ui/src/components/BudgetPolicyCard.test.tsx index 734a298dc61..b782842dfb9 100644 --- a/ui/src/components/BudgetPolicyCard.test.tsx +++ b/ui/src/components/BudgetPolicyCard.test.tsx @@ -29,11 +29,22 @@ describe("exact reservation editing", () => { const save = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Update reservation")!; expect(input.value).toBe("90071992547409.920000001"); expect(save.disabled).toBe(true); + for (const invalid of ["-1", "", "not an amount"]) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, invalid); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(save.disabled).toBe(true); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Enter a valid amount of zero or more."); + expect(input.getAttribute("aria-describedby")).toBe(container.querySelector('[role="alert"]')?.id); + } await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "0.000000001"); input.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => save.click()); expect(onReservationChange).toHaveBeenCalledWith("0.0000001"); + expect(container.querySelector('[role="alert"]')).toBeNull(); }); }); diff --git a/ui/src/components/BudgetPolicyCard.tsx b/ui/src/components/BudgetPolicyCard.tsx index 267a25b776c..6f5afd2b400 100644 --- a/ui/src/components/BudgetPolicyCard.tsx +++ b/ui/src/components/BudgetPolicyCard.tsx @@ -1,5 +1,5 @@ import { centsToUsd, usdToCents } from "@paperclipai/shared"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import type { BudgetPolicySummary } from "@paperclipai/shared"; import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react"; import { cn, formatCents } from "../lib/utils"; @@ -53,13 +53,16 @@ export function BudgetPolicyCard({ }, [summary.amount]); const [reservation, setReservation] = useState(centsToUsd(summary.reservationCents ?? 0)); + const reservationErrorId = useId(); useEffect(() => setReservation(centsToUsd(summary.reservationCents ?? 0)), [summary.reservationCents]); let reservationCents: string | null = null; try { if (!reservation.trim().startsWith("-")) reservationCents = usdToCents(reservation.trim()); } catch { /* Shown as invalid input below. */ } const reservationSection = onReservationChange ?
Enter a valid amount of zero or more.
}An estimate held before each run starts. Zero disables the estimate. Actual provider charges may exceed it.
diff --git a/ui/src/pages/Costs.quota.test.tsx b/ui/src/pages/Costs.quota.test.tsx index de21cf6101f..6c76e3582a9 100644 --- a/ui/src/pages/Costs.quota.test.tsx +++ b/ui/src/pages/Costs.quota.test.tsx @@ -34,6 +34,18 @@ describe("Costs account quota polling", () => { }); afterEach(() => { act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); }); const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); }; + it.each(["providers", "billers"] as const)("marks retained %s reports stale when their refresh fails", async (tab) => { + api.byBiller.mockResolvedValue([{ biller: "openai", costCents: 125, eventCount: 1, providerCount: 1, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0 }]); + await act(async () => root.render(Showing the last loaded data. Updates will resume automatically.
diff --git a/ui/storybook/fixtures/costsFinance.test.ts b/ui/storybook/fixtures/costsFinance.test.ts index 7f0422e28ea..3b8f672b612 100644 --- a/ui/storybook/fixtures/costsFinance.test.ts +++ b/ui/storybook/fixtures/costsFinance.test.ts @@ -44,6 +44,24 @@ describe("Costs story finance actions", () => { } }); + it("accepts equivalent invoice retries but rejects changed normalized content without altering charges", async () => { + const fixture = createCostsFinanceFixtures("preview"); + const invoice = { biller: "Example", externalId: "immutable-invoice", currency: "USD", lines: [ + { externalId: "fee-b", kind: "fee", amountCents: "50", occurredAt: charge.occurredAt }, + { externalId: "fee-a", kind: "fee", amountCents: "25", occurredAt: charge.occurredAt }, + ] }; + const saved = await (await fixture("accounting/invoices", request("accounting/invoices", invoice)))!.json(); + const equivalent = { ...invoice, lines: invoice.lines.slice().reverse().map(line => ({ ...line, amountCents: `${line.amountCents}.0000000` })) }; + expect(await (await fixture("accounting/invoices", request("accounting/invoices", equivalent)))!.json()).toEqual(saved); + for (const changed of [ + { ...invoice, currency: "EUR" }, + { ...invoice, lines: [{ ...invoice.lines[0], amountCents: "51" }, invoice.lines[1]] }, + { ...invoice, lines: [{ ...invoice.lines[0], occurredAt: "2000-01-01T00:00:00Z" }, invoice.lines[1]] }, + ]) expect((await fixture("accounting/invoices", request("accounting/invoices", changed)))!.status).toBe(409); + expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()).toMatchObject({ debitCents: 75, eventCount: 2 }); + expect(await (await fixture("accounting/invoices", request("accounting/invoices")))!.json()).toHaveLength(1); + }); + it("keeps timestamp bounds exact while date-only bounds include the full day", async () => { const fixture = createCostsFinanceFixtures("preview"); for (const [hour, id] of [["12", "at-boundary"], ["18", "after-boundary"]]) { diff --git a/ui/storybook/fixtures/costsFinance.ts b/ui/storybook/fixtures/costsFinance.ts index 8e89323edb6..a52c1aeaa9e 100644 --- a/ui/storybook/fixtures/costsFinance.ts +++ b/ui/storybook/fixtures/costsFinance.ts @@ -4,6 +4,7 @@ import { normalizeCents, createFinanceEventSchema, importBillingInvoiceSchema, i export function createCostsFinanceFixtures(companyId: string) { const events: FinanceEvent[] = []; const reports: BillingReconciliation[] = []; + const invoiceContents = new Map