diff --git a/ui/src/components/FinancialEventEntry.test.tsx b/ui/src/components/FinancialEventEntry.test.tsx index 55751ec06cd..c14ce1323c4 100644 --- a/ui/src/components/FinancialEventEntry.test.tsx +++ b/ui/src/components/FinancialEventEntry.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 { FinancialEventEntry } from "./FinancialEventEntry"; const mocks = vi.hoisted(() => ({ create: vi.fn(), @@ -154,6 +155,34 @@ describe("financial event entry", () => { expect(mocks.create.mock.calls[2][1].idempotencyKey).not.toBe(original[1].idempotencyKey); }); + it.each([400, 401, 403, 404, 422])("unlocks a charge rejected with %s so it can be edited or abandoned", async (status) => { + await render(); await click("Record or import charges"); + await fill("Provider or biller", "anthropic"); await fill("Amount (USD)", "2"); + mocks.create.mockRejectedValueOnce(new ApiError("Private rejection detail", status, {})); + await submit(); + const original = mocks.create.mock.calls[0][1]; + expect(document.body.textContent).not.toContain("This charge may already be saved"); + expect(document.body.textContent).not.toContain("Private rejection detail"); + expect([...document.querySelectorAll("form input")].every(input => !input.disabled)).toBe(true); + await click("Cancel"); await click("Record or import charges"); + await click("Import invoice"); + expect(document.querySelector('[aria-label="Financial invoice JSON"]')).not.toBeNull(); + await click("Record charge"); await fill("Amount (USD)", "3"); await submit(); + expect(mocks.create.mock.calls[1][1]).toMatchObject({ amountCents: "300.0000000", idempotencyKey: original.idempotencyKey }); + }); + + it.each([409, 500, 502])("keeps a charge with uncertain %s outcome immutable", async (status) => { + await render(); await click("Record or import charges"); + await fill("Provider or biller", "anthropic"); await fill("Amount (USD)", "2"); + mocks.create.mockRejectedValueOnce(new ApiError("Uncertain response", status, {})); + await submit(); + const original = mocks.create.mock.calls[0]; + expect(document.body.textContent).toContain("Confirm original charge"); + expect([...document.querySelectorAll("form input")].every(input => input.disabled)).toBe(true); + await click("Confirm original charge"); + expect(mocks.create.mock.calls[1]).toEqual(original); + }); + it("validates invoice input and sends the normalized invoice to the current company", async () => { await render(); await click("Record or import charges"); diff --git a/ui/src/components/FinancialEventEntry.tsx b/ui/src/components/FinancialEventEntry.tsx index e8463567d24..24059bfb086 100644 --- a/ui/src/components/FinancialEventEntry.tsx +++ b/ui/src/components/FinancialEventEntry.tsx @@ -9,6 +9,7 @@ import { usdToCents, } from "@paperclipai/shared"; import { costsApi } from "../api/costs"; +import { ApiError } from "../api/client"; import { accountingApi } from "../api/accounting"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -110,6 +111,13 @@ function Entry({ companyId }: { companyId: string }) { } return true; }, + onError: (error) => { + // These responses reject the request before it can save. Lost responses, + // server failures and idempotency conflicts must retain the original. + if (error instanceof ApiError && [400, 401, 403, 404, 422].includes(error.status)) { + setSubmittedCharge(null); + } + }, onSuccess: async (saved) => { if (!saved) return; setOpen(false); diff --git a/ui/src/pages/Costs.refresh.test.tsx b/ui/src/pages/Costs.refresh.test.tsx index 9ed062676ca..57317a3e511 100644 --- a/ui/src/pages/Costs.refresh.test.tsx +++ b/ui/src/pages/Costs.refresh.test.tsx @@ -135,6 +135,24 @@ describe.each([ expect(client.getQueryCache().getAll().map(q => q.queryHash).sort()).toEqual(keys); }); + it.each([ + ["Month to Date (UTC)", "2026-09-30T23:59:50Z"], + ["Year to Date", "2026-12-31T23:59:50Z"], + ])("starts a new %s report at its UTC boundary", async (period, boundary) => { + vi.setSystemTime(new Date(boundary)); + await render(); await click(period); + expect(container.textContent).toContain("$402.88"); + const previousFrom = api.summary.mock.calls.at(-1)![1]; + api.summary.mockRejectedValue(new Error("private failure")); + api.financeEvents.mockRejectedValue(new Error("private failure")); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); await settle(); + expect(api.summary.mock.calls.at(-1)![1]).not.toBe(previousFrom); + expect(container.textContent).not.toContain("$402.88"); + expect(container.textContent).not.toContain("Bender"); + expect(container.textContent).toContain("Cost data could not be loaded"); + expect(container.textContent).not.toContain("private failure"); + }); + 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")); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 07e71f5c6e4..20eb0d80eff 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -219,7 +219,9 @@ 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; + // Month/year-to-date become a different reporting period at their UTC + // boundary. Rolling windows retain identity as their lower bound advances. + const reportQueryFrom = ["custom", "mtd", "ytd"].includes(preset) ? from || undefined : undefined; const reportQueryTo = preset === "custom" ? to || undefined : preset; const { data: budgetData, isLoading: budgetLoading, error: budgetError } = useQuery({