fix: reset calendar reports and unlock rejected finance drafts

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-03 22:11:08 -07:00
1 parent 6fb456466a
commit 61eaa4f5bc
4 files changed
+58 -1

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 { 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<HTMLInputElement>("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<HTMLInputElement>("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");
@@ -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);
+18
View File
@@ -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"));
+3 -1
View File
@@ -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({