mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
fix: surface stale reports and invalid accounting edits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a5a523d3c0
commit
9ec4ac0932
6 files changed
+60
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? <div className="space-y-2">
|
||||
<label className="text-sm">Reserve per run (USD)
|
||||
<Input aria-label="Reserve per run (USD)" value={reservation} onChange={event => setReservation(event.target.value)} inputMode="decimal" />
|
||||
<Input aria-label="Reserve per run (USD)" aria-invalid={reservationCents === null} aria-describedby={reservationCents === null ? reservationErrorId : undefined}
|
||||
value={reservation} onChange={event => setReservation(event.target.value)} inputMode="decimal" />
|
||||
</label>
|
||||
{reservationCents === null && <p id={reservationErrorId} role="alert" className="text-xs text-destructive">Enter a valid amount of zero or more.</p>}
|
||||
<p className="text-xs text-muted-foreground">An estimate held before each run starts. Zero disables the estimate. Actual provider charges may exceed it.</p>
|
||||
<Button variant="outline" disabled={isSaving || reservationCents === null || reservationCents === (summary.reservationCents ?? "0.0000000")}
|
||||
onClick={() => { if (reservationCents !== null) onReservationChange(reservationCents); }}>Update reservation</Button>
|
||||
|
||||
@@ -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(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs initialTab={tab} /></QueryClientProvider></SidebarProvider></MemoryRouter>));
|
||||
await flush();
|
||||
expect(container.textContent).not.toContain("Showing the last loaded data");
|
||||
const report = tab === "providers" ? api.byProvider : api.byBiller;
|
||||
report.mockRejectedValue(new Error("Private report diagnostic"));
|
||||
await act(async () => { await client.invalidateQueries(); }); await flush();
|
||||
expect(container.textContent).toContain("Showing the last loaded data");
|
||||
expect(container.textContent).toContain("OpenAI");
|
||||
expect(container.textContent).not.toContain("Private report diagnostic");
|
||||
});
|
||||
it("keeps quota readings with a safe notice when the endpoint rejects", async () => {
|
||||
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs /></QueryClientProvider></SidebarProvider></MemoryRouter>));
|
||||
await flush();
|
||||
|
||||
@@ -323,7 +323,7 @@ export function Costs({
|
||||
return map;
|
||||
}, [spendData?.byAgentModel]);
|
||||
|
||||
const { data: providerData } = useQuery({
|
||||
const { data: providerData, error: providerError } = useQuery({
|
||||
queryKey: queryKeys.usageByProvider(companyId, reportQueryFrom, reportQueryTo),
|
||||
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
|
||||
enabled: !!selectedCompanyId && customReady && (mainTab === "providers" || mainTab === "billers"),
|
||||
@@ -331,7 +331,7 @@ export function Costs({
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: billerData } = useQuery({
|
||||
const { data: billerData, error: billerError } = useQuery({
|
||||
queryKey: queryKeys.usageByBiller(companyId, reportQueryFrom, reportQueryTo),
|
||||
queryFn: () => costsApi.byBiller(companyId, from || undefined, to || undefined),
|
||||
enabled: !!selectedCompanyId && customReady && mainTab === "billers",
|
||||
@@ -339,7 +339,7 @@ export function Costs({
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: weekData } = useQuery({
|
||||
const { data: weekData, error: weekError } = useQuery({
|
||||
queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
|
||||
queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
|
||||
enabled: !!selectedCompanyId && (mainTab === "providers" || mainTab === "billers"),
|
||||
@@ -347,7 +347,7 @@ export function Costs({
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: weekBillerData } = useQuery({
|
||||
const { data: weekBillerData, error: weekBillerError } = useQuery({
|
||||
queryKey: queryKeys.usageByBiller(companyId, weekRange.from, weekRange.to),
|
||||
queryFn: () => costsApi.byBiller(companyId, weekRange.from, weekRange.to),
|
||||
enabled: !!selectedCompanyId && mainTab === "billers",
|
||||
@@ -355,7 +355,7 @@ export function Costs({
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const { data: windowData } = useQuery({
|
||||
const { data: windowData, error: windowError } = useQuery({
|
||||
queryKey: queryKeys.usageWindowSpend(companyId),
|
||||
queryFn: () => costsApi.windowSpend(companyId),
|
||||
enabled: !!selectedCompanyId && mainTab === "providers",
|
||||
@@ -677,7 +677,7 @@ export function Costs({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError)) ? (
|
||||
{((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError) || providerError || billerError || weekError || weekBillerError || windowError) ? (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Showing the last loaded data. Updates will resume automatically.
|
||||
</p>
|
||||
|
||||
@@ -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"]]) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { normalizeCents, createFinanceEventSchema, importBillingInvoiceSchema, i
|
||||
export function createCostsFinanceFixtures(companyId: string) {
|
||||
const events: FinanceEvent[] = [];
|
||||
const reports: BillingReconciliation[] = [];
|
||||
const invoiceContents = new Map<string, string>();
|
||||
const createdAt = new Date();
|
||||
let sequence = 0;
|
||||
const id = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
|
||||
@@ -54,11 +55,18 @@ export function createCostsFinanceFixtures(companyId: string) {
|
||||
if (resource === "accounting/invoices") {
|
||||
if (request.method === "GET") return Response.json(reports.map(report => report.invoice));
|
||||
const input = importBillingInvoiceSchema.parse(await request.json());
|
||||
input.lines.sort((a, b) => a.externalId.localeCompare(b.externalId));
|
||||
const canonical = (value: unknown): unknown => value instanceof Date ? value.toISOString()
|
||||
: Array.isArray(value) ? value.map(canonical)
|
||||
: value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value;
|
||||
const contents = JSON.stringify(canonical(input));
|
||||
let report = reports.find(row => row.invoice.biller === input.biller && row.invoice.externalId === input.externalId);
|
||||
if (report && invoiceContents.get(report.invoice.id) !== contents) return Response.json({ error: "Invoice identifier already has different contents" }, { status: 409 });
|
||||
if (!report) {
|
||||
const invoice = { id: id(), companyId, biller: input.biller, externalId: input.externalId, currency: input.currency, createdAt: createdAt.toISOString() };
|
||||
report = { invoice, lines: input.lines.map(line => ({ id: id(), externalId: line.externalId, kind: line.kind, amountCents: line.amountCents, status: "unmatched", matchedEventId: null, recordedCents: null, differenceCents: null })) };
|
||||
reports.push(report);
|
||||
invoiceContents.set(invoice.id, contents);
|
||||
for (const line of input.lines) record({ idempotencyKey: `preview-invoice:${invoice.id}:${line.externalId}`, externalInvoiceId: invoice.externalId, biller: input.biller, currency: input.currency, amountCents: line.amountCents, occurredAt: line.occurredAt, direction: line.kind === "credit" ? "credit" : "debit", eventKind: line.kind === "credit" ? "credit_refund" : line.kind === "fee" ? "platform_fee" : "inference_charge", description: `Preview invoice ${input.externalId}` });
|
||||
}
|
||||
return Response.json(report.invoice);
|
||||
|
||||
Reference in new issue
Block a user