mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix: reset calendar reports and unlock rejected finance drafts
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user