From 3415e577b904d6ee7611a97105bfa923765b230a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 4 Oct 2026 02:12:13 -0700 Subject: [PATCH 1/2] perf: index receipt recovery outside company accounting locks Reuse a batch index of immutable receipt identities so settlements read only matching or newly published files. Verify unrelated receipts are read once, company locks remain available during indexing, and late publications still settle correctly. Co-Authored-By: Paperclip --- doc/COST-ACCOUNTING.md | 2 + .../cost-accounting-operations-edges.test.ts | 49 +++++++++++++++++++ server/src/services/run-cost-accounting.ts | 10 ++-- server/src/services/usage-receipts.ts | 43 ++++++++++++++-- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index 31c572ca6ed..cd4959a5860 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -75,6 +75,8 @@ Before replacing a stopped run's recorder, recovery saves every pending spool re Settlement also drains every pending spool receipt for the run under the company accounting lock before checking completeness or acknowledging usage. A newer partial receipt prevents settlement of an older complete snapshot. Recovered journal writes and ledger settlement share a transaction; spool files are removed only after its commit, so a failed charge or projection write leaves the original evidence available for retry. A concurrent replay rename causes a rescan; repeated file movement defers settlement. +Recovery indexes immutable spool-file identities once per batch, outside company locks. Each settlement skips indexed files belonging to other runs, revalidates its matching receipts, and checks newly published or renamed files. Standalone settlement builds the same index before taking its lock. This avoids repeatedly reading unrelated receipt contents while admissions and ledger writes wait. + The spool survives process death on persistent local storage, including the tested boundary before the first database write. It does not survive destruction of that storage, and cannot recover provider activity that was never emitted as usage. Back up persistent instance storage together with PostgreSQL. No arbitrary provider-log scraping or automatic provider re-execution occurs during accounting recovery. Board-only routes under `/api/companies/:companyId/accounting` provide: diff --git a/server/src/__tests__/cost-accounting-operations-edges.test.ts b/server/src/__tests__/cost-accounting-operations-edges.test.ts index 706ad581bcc..7c00e6ffa87 100644 --- a/server/src/__tests__/cost-accounting-operations-edges.test.ts +++ b/server/src/__tests__/cost-accounting-operations-edges.test.ts @@ -303,6 +303,55 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await fs.readdir(spool)).toHaveLength(unrelated.length + 1); }); + it("indexes a recovery backlog once outside company locks and still sees newly published receipts", async () => { + const f = await fixture(), other = await fixture(), spool = usageReceiptSpoolPath(); + const runs = [f.run]; + for (let i = 0; i < 7; i++) { + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, status: "running" }).returning(); + runs.push(run); + } + for (const run of runs) { + const recorder = await createRunUsageRecorder(db, { companyId: f.company.id, runId: run.id, adapterType: "process" }); + await recorder.capture({ complete: true, usage: { inputTokens: 50, outputTokens: 5 }, costUsd: 0.05 }); + await db.update(heartbeatRuns).set({ status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, run.id)); + } + const [first] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.run.id)); + const envelope: UsageReceiptEnvelope = { schema: "paperclip/accounting-receipt/v1", id: randomUUID(), companyId: other.company.id, + runId: other.run.id, sourceId: randomUUID(), sequence: 1, receivedAt: new Date().toISOString(), adapterType: "process", receipt: { complete: false } }; + const foreignFiles = new Set(); + for (let i = 0; i < 256; i++) foreignFiles.add(await spoolUsageReceipt({ ...envelope, id: randomUUID(), sequence: i + 1 })); + const reads = new Map(), read = fs.readFile.bind(fs); + let checkedLock = false, lockWasAvailable = false; + const spy = vi.spyOn(fs, "readFile").mockImplementation(async (file, options) => { + if (typeof file === "string" && foreignFiles.has(file)) { + reads.set(file, (reads.get(file) ?? 0) + 1); + if (!checkedLock) { + checkedLock = true; + await db.transaction(async tx => { + await tx.execute(sql`set local lock_timeout = '100ms'`); + await tx.select().from(companies).where(eq(companies.id, f.company.id)).for("no key update"); + }); + lockWasAvailable = true; + // Published after the index's directory snapshot: the locked drain + // must discover it instead of trusting a stale batch inventory. + await spoolUsageReceipt({ ...envelope, id: randomUUID(), companyId: f.company.id, runId: f.run.id, + sourceId: String(first.usageJson?.accountingReceiptSourceId), sequence: 2, + receipt: { complete: true, usage: { inputTokens: 200, outputTokens: 20 }, costUsd: 0.2 } }); + } + } + return read(file, options); + }); + try { await reconcileRunCosts(db); } finally { spy.mockRestore(); } + expect(lockWasAvailable).toBe(true); + expect(reads.size).toBe(256); + expect([...reads.values()]).toEqual(Array(256).fill(1)); + const charges = await db.select().from(costEvents).where(eq(costEvents.companyId, f.company.id)); + expect(charges).toHaveLength(8); + expect(charges.find(charge => charge.heartbeatRunId === f.run.id)).toMatchObject({ inputTokens: 200, costCents: 20 }); + expect((await costService(db).summary(f.company.id)).spendCents).toBe(55); + await Promise.all([...foreignFiles].map(file => fs.rm(file))); + }); + it("reports over-limit advisory budgets without blocking, and tolerates legacy active zero limits", async () => { const f = await fixture(), budgets = budgetService(db); const policy = await budgets.upsertPolicy(f.company.id, { scopeType: "company", scopeId: f.company.id, amount: 1, hardStopEnabled: false }, "board"); diff --git a/server/src/services/run-cost-accounting.ts b/server/src/services/run-cost-accounting.ts index 27eae9496b7..8f825c03d0b 100644 --- a/server/src/services/run-cost-accounting.ts +++ b/server/src/services/run-cost-accounting.ts @@ -6,7 +6,7 @@ import { createCostEventInTransaction } from "./costs.js"; import { budgetService, deliverBudgetEnforcement, type BudgetServiceHooks } from "./budgets.js"; import { logger } from "../middleware/logger.js"; import { promises as fs } from "node:fs"; -import { recoverPendingRunUsageReceipts } from "./usage-receipts.js"; +import { indexPendingUsageReceipts, recoverPendingRunUsageReceipts, type UsageReceiptIndex } from "./usage-receipts.js"; const terminalStatuses = ["succeeded", "failed", "timed_out", "cancelled", "interrupted"]; const object = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; @@ -15,9 +15,10 @@ const amount = (value: unknown) => typeof value === "number" && Number.isFinite( /** The run's persisted usage is a durable receipt. Ledger, runtime totals, and * acknowledgement commit together, including after a restart or failed run. */ -export async function accountRunCost(db: Db, runId: string, hooks: BudgetServiceHooks = {}) { +export async function accountRunCost(db: Db, runId: string, hooks: BudgetServiceHooks = {}, receiptIndex?: UsageReceiptIndex) { const [identity] = await db.select({ companyId: heartbeatRuns.companyId }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); if (!identity) return false; + const index = receiptIndex ?? await indexPendingUsageReceipts(); let recoveredFiles: string[] = []; const accounted = await withAccountingTransaction(db, identity.companyId, async (tx, publications) => { let [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).for("update"); @@ -29,7 +30,7 @@ export async function accountRunCost(db: Db, runId: string, hooks: BudgetService // The bounded global replay can leave a newer receipt behind an older // complete snapshot. Drain this run under the accounting lock before // deciding completeness, and retain disk evidence until the outer commit. - recoveredFiles = await recoverPendingRunUsageReceipts(tx, { ...identity, runId }, undefined, true); + recoveredFiles = await recoverPendingRunUsageReceipts(tx, { ...identity, runId }, undefined, { retainFiles: true, index }); [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); const preProviderFailure = object(object(run.resultJson).executionRecovery).providerWorkStarted === false || object(run.usageJson).accountingProviderWorkStarted === false; @@ -145,8 +146,9 @@ export async function reconcileRunCosts(db: Db, hooks: BudgetServiceHooks = {}) sql`(${heartbeatRuns.usageJson}->>'accountingReceiptReady' is distinct from 'false' or ${heartbeatRuns.resultJson}->'executionRecovery'->>'providerWorkStarted' = 'false' or ${heartbeatRuns.usageJson}->>'accountingProviderWorkStarted' = 'false')`, )).orderBy(asc(heartbeatRuns.updatedAt), asc(heartbeatRuns.id)).limit(100); let accounted = 0; + const receiptIndex = pending.length > 0 ? await indexPendingUsageReceipts() : undefined; for (const run of pending) { - try { if (await accountRunCost(db, run.id, hooks)) accounted++; } + try { if (await accountRunCost(db, run.id, hooks, receiptIndex)) accounted++; } catch (error) { // Move failed attempts behind other pending work so a poisoned receipt // cannot monopolize the bounded batch on every recovery tick. diff --git a/server/src/services/usage-receipts.ts b/server/src/services/usage-receipts.ts index bc675ae3d43..d0788774078 100644 --- a/server/src/services/usage-receipts.ts +++ b/server/src/services/usage-receipts.ts @@ -113,7 +113,39 @@ export async function replayUsageReceipts(db: Db, directory = usageReceiptSpoolP return { replayed, failed }; } -export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: string; runId: string }, directory = usageReceiptSpoolPath(), retainFiles = false) { +type ReceiptIdentity = { companyId: unknown; runId: unknown } | null; +export type UsageReceiptIndex = Map; +function receiptIdentity(raw: unknown): ReceiptIdentity { + return raw && typeof raw === "object" && "companyId" in raw && "runId" in raw + ? { companyId: raw.companyId, runId: raw.runId } : null; +} + +/** Build once per recovery batch, before acquiring any accounting lock. + * Spool publications are immutable, uniquely named files. Cache identities, + * never receipt contents: matching evidence is revalidated during settlement. */ +export async function indexPendingUsageReceipts(directory = usageReceiptSpoolPath()): Promise { + const index: UsageReceiptIndex = new Map(); + let names: string[]; + try { names = await fs.readdir(directory); } + // Indexing is only an optimization. The locked drain reports missing or + // unreadable storage through the existing per-run recovery error path. + catch { return index; } + for (const name of names.filter(name => name.endsWith(".json"))) { + const file = path.join(directory, name); + try { + const info = await fs.lstat(file); + if (!info.isFile() || info.size > 1024 * 1024) { index.set(file, null); continue; } + index.set(file, receiptIdentity(JSON.parse(await fs.readFile(file, "utf8")))); + } catch (error) { + if (error instanceof SyntaxError) index.set(file, null); + // Unreadable or concurrently moved entries remain unknown and are + // checked again under the lock, preserving per-run failure reporting. + } + } + return index; +} + +export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: string; runId: string }, directory = usageReceiptSpoolPath(), options: { retainFiles?: boolean; index?: UsageReceiptIndex } = {}) { // The bounded sweep may not reach this run. Its durable evidence must // reach the journal before source replacement or ledger acknowledgement. // A failed save aborts the operation so recovery can retry. @@ -128,6 +160,8 @@ export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: for (const name of names.filter(name => name.endsWith(".json"))) { const file = path.join(directory, name); if (recovered.has(file)) continue; + const indexed = options.index?.get(file); + if (indexed !== undefined && (indexed?.companyId !== input.companyId || indexed?.runId !== input.runId)) continue; let raw: unknown; try { const info = await fs.lstat(file); @@ -140,13 +174,14 @@ export async function recoverPendingRunUsageReceipts(db: Db, input: { companyId: if (error instanceof SyntaxError) continue; throw error; } - if (!raw || typeof raw !== "object" || !("companyId" in raw) || !("runId" in raw) - || raw.companyId !== input.companyId || raw.runId !== input.runId) continue; + const identity = receiptIdentity(raw); + options.index?.set(file, identity); + if (identity?.companyId !== input.companyId || identity?.runId !== input.runId) continue; await persistUsageReceipt(db, envelopeSchema.parse(raw)); // Settlement uses the caller's transaction: deleting before its commit // would lose the only durable evidence if the later ledger write fails. recovered.add(file); - if (!retainFiles) await fs.rm(file, { force: true }); + if (!options.retainFiles) await fs.rm(file, { force: true }); } if (!moved) return [...recovered]; } From e1a30434e267ddad30a8081106b12ecbad99ae43 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 4 Oct 2026 02:17:30 -0700 Subject: [PATCH 2/2] fix: align cost displays and preview timelines with accounting contracts Preserve legacy Gemini cache totals, compare provider pace warnings with the displayed company cap, and sort and limit preview financial events like production. Cover all three review cases, including both Costs surfaces. Co-Authored-By: Paperclip --- doc/COST-ACCOUNTING.md | 2 ++ ui/src/lib/cost-reporting.test.ts | 8 +++---- ui/src/lib/utils.ts | 3 ++- ui/src/pages/Costs.test.tsx | 28 ++++++++++++++++++++++ ui/src/pages/Costs.tsx | 13 +++------- ui/storybook/fixtures/costsFinance.test.ts | 18 ++++++++++++++ ui/storybook/fixtures/costsFinance.ts | 12 ++++++++-- 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index 59e287515d1..2d7d930cc6d 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -44,6 +44,8 @@ Cost and finance endpoints default to the current UTC month through now. Explici Cost summaries expose `eventCount`, `estimatedEventCount`, `unpricedEventCount`, `pendingRunCount`, and `pricingComplete`. Estimated charges count toward budgets, while the UI distinguishes estimates from provider-reported dollars. Agent totals and expanded model rows show **Estimated** when every charge in that group is estimated, or **Partially estimated** for a mix. Groups with no estimates have no estimate badge. These labels follow the selected date range and use ledger-event counts, not run counts; they do not infer pricing status from the provider or model name. `pricingComplete` means no missing prices or pending receipts; it does not mean invoice reconciliation has occurred. The Costs page warns when totals are incomplete. Provider cards include cached tokens and calculate subscription shares using each token once. Reports normalize historical OpenAI events without a receipt fingerprint from inclusive input to exclusive input before grouping, so mixed historical/new totals count cache reads once across agents, models, providers, billers, projects, issues, and rolling windows. This read-time conversion preserves original ledger rows and charges; legacy Anthropic input already excludes cache reads. Provider spend is compared with the real company budget, not a fabricated proportional allocation. Run displays prefer cache-adjusted prices and preserve explicit zeroes. +Provider pace warnings project month-to-date spend across the UTC month and compare it with the same company cap displayed by the bar. Run-history totals preserve separate cached input for legacy Claude and Gemini snapshots while retaining inclusive legacy Codex totals. Storybook finance timelines use the production ordering (event time, then creation time, newest first) and request limits, including after importing older invoices. + Gemini token normalization follows the [CLI stream statistics](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/output/stream-json-formatter.ts) and [API usage metadata](https://ai.google.dev/api/generate-content#UsageMetadata): cached prompt tokens are separated from input, and thinking tokens contribute to output. ## Upgrade and operational limits diff --git a/ui/src/lib/cost-reporting.test.ts b/ui/src/lib/cost-reporting.test.ts index 58f78c1c9e6..f5a15e51579 100644 --- a/ui/src/lib/cost-reporting.test.ts +++ b/ui/src/lib/cost-reporting.test.ts @@ -19,12 +19,12 @@ describe("cost presentation", () => { expect(visibleRunCostUsd({ billingType: "subscription_included", costUsd: 50 })).toBe(0); }); - it("includes separate legacy Claude cache reads without changing legacy Codex totals", () => { + it.each(["anthropic", "google"])("includes separate legacy %s cache reads without changing legacy Codex totals", (provider) => { const tokens = { inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 }; - expect(visibleRunTokenTotal({ ...tokens, provider: "anthropic" })).toBe(190); + expect(visibleRunTokenTotal({ ...tokens, provider })).toBe(190); expect(visibleRunTokenTotal({ ...tokens, provider: "openai" })).toBe(110); - expect(visibleRunTokenTotal({ ...tokens, provider: "anthropic", accountingReceiptId: "saved-receipt" })).toBe(190); - expect(visibleRunTokenTotal({ provider: "anthropic", input_tokens: 100, cache_read_input_tokens: 80, output_tokens: 10 })).toBe(190); + expect(visibleRunTokenTotal({ ...tokens, provider, accountingReceiptId: "saved-receipt" })).toBe(190); + expect(visibleRunTokenTotal({ provider, input_tokens: 100, cache_read_input_tokens: 80, output_tokens: 10 })).toBe(190); }); it("formats finance amounts in their recorded currency", () => { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 37dc38eaf4d..d709364c246 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -179,7 +179,7 @@ function readRunCostUsd(payload: Record | null): number | null return null; } -/** Receipt-backed runs and legacy Claude snapshots store uncached input +/** Receipt-backed runs and legacy Claude/Gemini snapshots store uncached input * separately. Older Codex input already includes its cached tokens. Unknown * legacy formats retain their original input-plus-output interpretation. */ export function visibleRunTokenTotal(usage: Record | null | undefined): number { @@ -194,6 +194,7 @@ export function visibleRunTokenTotal(usage: Record | null | und const output = count("outputTokens", "output_tokens"); const cached = count("cachedInputTokens", "cached_input_tokens", "cache_read_input_tokens"); const inputExcludesCached = usage?.provider === "anthropic" + || usage?.provider === "google" || (typeof usage?.accountingReceiptId === "string" && usage.accountingReceiptId.length > 0); return input + output + (inputExcludesCached ? cached : 0); } diff --git a/ui/src/pages/Costs.test.tsx b/ui/src/pages/Costs.test.tsx index 7662422871c..aeaa8c7c46d 100644 --- a/ui/src/pages/Costs.test.tsx +++ b/ui/src/pages/Costs.test.tsx @@ -45,6 +45,8 @@ vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }), })); +vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -167,6 +169,32 @@ describe("Shared Costs surfaces", () => { queryClient.clear(); }); + it.each(surfaces)("uses the displayed company cap for provider warnings on the %s page", async (_name, props) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-16T00:30:00Z")); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + try { + for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]); + costsApiMocks.summary.mockResolvedValue({ spendCents: 800, budgetCents: 1000, pricingComplete: true }); + const row = { model: "test", billingType: "metered_api", inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, + apiRunCount: 1, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionCachedInputTokens: 0, subscriptionOutputTokens: 0 }; + costsApiMocks.byProvider.mockResolvedValue([ + { ...row, provider: "openai", biller: "openai", costCents: 100 }, + { ...row, provider: "google", biller: "google", costCents: 700 }, + ]); + root = createRoot(container); + await act(async () => root.render()); + await act(async () => { await vi.waitFor(() => expect(container.querySelectorAll('[aria-label^="Period spend:"]')).toHaveLength(2)); }); + const bar = (percent: number) => container.querySelector(`[aria-label="Period spend: ${percent}%"]`)!.parentElement!; + expect(bar(10).querySelector(".bg-destructive")).toBeNull(); + expect(bar(70).querySelector(".bg-destructive")).not.toBeNull(); + expect(container.textContent).toContain("10% of company budget"); + expect(container.textContent).toContain("70% of company budget"); + await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "All Time")!.click()); + await act(async () => { await vi.waitFor(() => expect(container.querySelector('[aria-label^="Period spend:"]')).toBeNull()); }); + } finally { queryClient.clear(); vi.useRealTimers(); } + }); + it.each(surfaces)("shows incomplete accounting and currency boundaries on the %s page", async (_name, props) => { for (const mock of Object.values(costsApiMocks)) mock.mockResolvedValue([]); costsApiMocks.summary.mockResolvedValue({ spendCents: 12.4, budgetCents: 0, pricingComplete: false, unpricedEventCount: 2, pendingRunCount: 1 }); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index be222961a17..cc68f94b023 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -458,20 +458,13 @@ export function Costs({ if (preset !== "mtd") return map; const budget = spendData?.summary.budgetCents ?? 0; if (budget <= 0) return map; - const totalSpend = spendData?.summary.spendCents ?? 0; const now = new Date(); - const daysElapsed = now.getDate(); - const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate(); + const daysElapsed = now.getUTCDate(); + const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate(); for (const [providerKey, rows] of byProvider) { const providerCostCents = rows.reduce((sum, row) => sum + row.costCents, 0); - const providerShare = totalSpend > 0 ? providerCostCents / totalSpend : 0; - const providerBudget = budget * providerShare; - if (providerBudget <= 0) { - map.set(providerKey, false); - continue; - } const burnRate = providerCostCents / Math.max(daysElapsed, 1); - map.set(providerKey, providerCostCents + burnRate * (daysInMonth - daysElapsed) > providerBudget); + map.set(providerKey, providerCostCents + burnRate * (daysInMonth - daysElapsed) > budget); } return map; }, [preset, spendData, byProvider]); diff --git a/ui/storybook/fixtures/costsFinance.test.ts b/ui/storybook/fixtures/costsFinance.test.ts index 3b8f672b612..7377bab4e6b 100644 --- a/ui/storybook/fixtures/costsFinance.test.ts +++ b/ui/storybook/fixtures/costsFinance.test.ts @@ -74,4 +74,22 @@ describe("Costs story finance actions", () => { expect(wholeDay).toHaveLength(2); }); + it("keeps recent events chronological and bounded after importing an older invoice", async () => { + const fixture = createCostsFinanceFixtures("preview"); + await fixture("finance-events", request("finance-events", { ...charge, occurredAt: "2026-02-01T00:00:00Z" })); + const lines = Array.from({ length: 30 }, (_, i) => ({ externalId: `older-${i}`, kind: "fee", amountCents: "10", occurredAt: new Date(Date.UTC(2026, 0, i + 1)).toISOString() })); + await fixture("accounting/invoices", request("accounting/invoices", { biller: "Example", externalId: "older-invoice", currency: "USD", lines })); + const rows = await (await fixture("costs/finance-events", request("costs/finance-events?limit=18")))!.json(); + expect(rows).toHaveLength(18); + expect(rows.map((row: { occurredAt: string }) => row.occurredAt)).toEqual([ + "2026-02-01T00:00:00.000Z", ...Array.from({ length: 17 }, (_, i) => new Date(Date.UTC(2026, 0, 30 - i)).toISOString()), + ]); + expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()).toMatchObject({ eventCount: 31, debitCents: 425 }); + const bounded = await (await fixture("costs/finance-events", request("costs/finance-events?from=2026-01-25&to=2026-01-30&limit=3")))!.json(); + expect(bounded.map((row: { occurredAt: string }) => row.occurredAt)).toEqual(lines.slice(27, 30).reverse().map(line => line.occurredAt)); + for (const limit of ["0", "-1", "1.5", "501", "invalid", "1&limit=2"]) { + expect((await fixture("costs/finance-events", request(`costs/finance-events?limit=${limit}`)))!.status).toBe(400); + } + }); + }); diff --git a/ui/storybook/fixtures/costsFinance.ts b/ui/storybook/fixtures/costsFinance.ts index a52c1aeaa9e..faec984858a 100644 --- a/ui/storybook/fixtures/costsFinance.ts +++ b/ui/storybook/fixtures/costsFinance.ts @@ -27,7 +27,7 @@ export function createCostsFinanceFixtures(companyId: string) { description: input.description ?? null, eventKind: input.eventKind, direction: input.direction, biller: input.biller, amountCents: Number(input.amountCents), amountCentsExact: String(input.amountCents), currency: input.currency, estimated: false, externalInvoiceId: input.externalInvoiceId ?? null, - idempotencyKey: input.idempotencyKey ?? null, metadataJson: input.metadataJson ?? null, occurredAt: new Date(input.occurredAt), createdAt, + idempotencyKey: input.idempotencyKey ?? null, metadataJson: input.metadataJson ?? null, occurredAt: new Date(input.occurredAt), createdAt: new Date(), }; events.unshift(event); return event; @@ -38,7 +38,15 @@ export function createCostsFinanceFixtures(companyId: string) { const until = to ? new Date(/^\d{4}-\d{2}-\d{2}$/.test(to) ? `${to}T23:59:59.999Z` : to) : null; const visible = events.filter(row => (!from || row.occurredAt >= new Date(from)) && (!until || row.occurredAt <= until)); if (resource === "costs/finance-summary") return Response.json({ companyId, ...summary(visible), eventCount: visible.length, currencies: [...new Set(visible.map(row => row.currency))].map(currency => summary(visible, currency)) }); - if (resource === "costs/finance-events") return Response.json(visible); + if (resource === "costs/finance-events") { + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit == null || rawLimit === "" ? 100 : Number(rawLimit); + if (url.searchParams.getAll("limit").length > 1 || !Number.isInteger(limit) || limit <= 0 || limit > 500) { + return Response.json({ error: "invalid 'limit' value" }, { status: 400 }); + } + return Response.json(visible.sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime() + || b.createdAt.getTime() - a.createdAt.getTime()).slice(0, limit)); + } if (resource === "costs/finance-by-biller" || resource === "costs/finance-by-kind") { const byBiller = resource.endsWith("biller"); const charges = visible.filter(row => row.metadataJson?.source !== "provider_cost_report");