mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Merge final accounting review fixes into the regression gate
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
849780eb33
10 files changed
+163
-25
No files matched your search
@@ -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
|
||||
@@ -75,6 +77,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:
|
||||
|
||||
@@ -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<string>();
|
||||
for (let i = 0; i < 256; i++) foreignFiles.add(await spoolUsageReceipt({ ...envelope, id: randomUUID(), sequence: i + 1 }));
|
||||
const reads = new Map<string, number>(), 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");
|
||||
|
||||
@@ -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<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, ReceiptIdentity>;
|
||||
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<UsageReceiptIndex> {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+2
-1
@@ -179,7 +179,7 @@ function readRunCostUsd(payload: Record<string, unknown> | 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<string, unknown> | null | undefined): number {
|
||||
@@ -194,6 +194,7 @@ export function visibleRunTokenTotal(usage: Record<string, unknown> | 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);
|
||||
}
|
||||
|
||||
@@ -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(<MemoryRouter><QueryClientProvider client={queryClient}><Costs {...props} initialTab="providers" /></QueryClientProvider></MemoryRouter>));
|
||||
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 });
|
||||
|
||||
+3
-10
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user