Merge final quota and dashboard review fixes into accounting gate

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-03 22:43:35 -07:00
commit fd1121e649
12 files changed
+232 -82

No files matched your search

+8
View File
@@ -532,3 +532,11 @@ A reconnect during credential resolution defers the poll instead of associating
one account's quota with another revision. Both Costs surfaces retain matching
successful observations on transient failures and clear them on authentication
failure or credential rotation.
Managed runtime token write-back retries a company-lock timeout twice (three
30-second acquisition attempts), so a slow quota exchange does not discard a
different account's replacement tokens. Any remaining write-back error preserves
the private runtime home for retry; cleanup deletes it only after a successful
transaction or an intentional freshness/authorization discard. These retained
homes are recovery evidence, not a background replay queue; persistent database
or lock failures still require operator intervention.
@@ -12,6 +12,8 @@ import { createDb, companies, agents, heartbeatRuns, companyMemberships, connect
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
import { aiConnectionService } from "../services/ai-connections.js";
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
import { toolAccessService } from "../services/tool-access.js";
@@ -131,6 +133,66 @@ describe("managed AI connections", () => {
} finally { releaseBody.resolve(); await saving.catch(() => {}); request.mockRestore(); }
});
it("retries another account's token write-back while a slow quota exchange holds the company lock", async () => {
const auth = (account: string, marker: string) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: marker === "old" ? "2026-09-10T10:00:00Z" : "2026-09-10T11:00:00Z" });
const quotaOwner = "slow-quota-owner", runOwner = "slow-quota-other-run";
await db.insert(companyMemberships).values([quotaOwner, runOwner].map(principalId => ({ companyId, principalId, principalType: "user" as const, status: "active" as const, membershipRole: "member" as const })));
for (const owner of [quotaOwner, runOwner]) await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth(owner, "old"));
const [quotaRow] = await service.quotaAccounts(companyId, quotaOwner);
const started = deferredSignal(), release = deferredSignal();
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
started.resolve(); await release.promise;
return Response.json({ access_token: "quota-fresh", refresh_token: "quota-replacement" });
});
const refreshing = service.refreshQuotaCredential(quotaRow, auth(quotaOwner, "old"), new AbortController().signal);
let run: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
let cleaning: Promise<void> | undefined;
let lock: ReturnType<typeof vi.spyOn> | undefined;
try {
await started.promise;
// This different account starts after the refresh's active-run check.
run = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: runOwner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
const authFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
await writeFile(authFile, auth(runOwner, "replacement"));
// Inject the two 30-second acquisition expirations, then use the real
// still-held lock and real Postgres write-back for the final attempt.
lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock")
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }))
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }));
cleaning = run.cleanup();
await vi.waitFor(() => expect(lock).toHaveBeenCalledTimes(3));
expect(await readFile(authFile, "utf8")).toBe(auth(runOwner, "replacement"));
release.resolve();
await Promise.all([refreshing, cleaning]);
const [runRow] = await service.quotaAccounts(companyId, runOwner);
expect(await service.credential(runRow)).toBe(auth(runOwner, "replacement"));
expect(JSON.parse(await service.credential(quotaRow))).toMatchObject({ tokens: { access_token: "quota-fresh", refresh_token: "quota-replacement" } });
await expect(access(run.home!)).rejects.toMatchObject({ code: "ENOENT" });
} finally { release.resolve(); await Promise.allSettled([refreshing, cleaning]); lock?.mockRestore(); request.mockRestore(); }
});
it.each(["lock", "database"])("preserves replacement tokens after exhausted %s write-back and permits a later retry", async failure => {
const owner = `retained-writeback-${failure}`;
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
const run = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
const authFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
await writeFile(authFile, auth("11"));
const injected = failure === "lock"
? vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock").mockRejectedValue(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }))
: vi.spyOn(db, "transaction").mockRejectedValue(new Error("database unavailable"));
try {
await expect(run.cleanup()).rejects.toThrow(failure === "lock" ? "lock timeout" : "database unavailable");
expect(injected).toHaveBeenCalledTimes(failure === "lock" ? 3 : 1);
expect(await readFile(authFile, "utf8")).toBe(auth("11"));
} finally { injected.mockRestore(); }
await run.cleanup();
const [row] = await service.quotaAccounts(companyId, owner);
expect(await service.credential(row)).toBe(auth("11"));
await expect(access(run.home!)).rejects.toMatchObject({ code: "ENOENT" });
});
it("serializes quota exchange with a concurrent secret edit and runtime credential read", async () => {
const owner = "quota-race-owner";
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
+79 -67
View File
@@ -12,6 +12,7 @@ import {
import { aiConnectionService } from "./ai-connections.js";
import { secretService } from "./secrets.js";
import { decideCodexAuthMerge, withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
@@ -310,75 +311,86 @@ export async function prepareManagedAiRuntime(
identity,
home,
cleanup: async () => {
try {
if (subscriptionFile) {
const refreshed = await readFile(authFile, "utf8");
if (refreshed !== value)
await withAccountHomeSecretMutationLock(undefined, input.companyId, () => db.transaction(async (tx) => {
const [grant] = await tx
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, selection.grant.id),
eq(connectionGrants.companyId, input.companyId),
),
)
.for("update");
// A missing or revoked grant blocks the write-back. Among
// active copies, the merge decision below keeps the
// credential with the newest provider freshness field.
if (!grant || grant.status !== "active") return;
const ref = grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
if (!ref) return;
// Lock the referenced secret row for the rest of this
// transaction. The grant-row lock above does not cover it,
// so an authorized rotation of this secret could otherwise
// land between the read and the write below and be
// overwritten by this stale write-back.
await tx
.select({ id: companySecrets.id })
.from(companySecrets)
.where(
and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, input.companyId),
),
)
.for("update");
const current = await aiConnectionService(
tx as unknown as Db,
).credential({ ...selection, grant });
const destination = path.join(
providerHome,
"current-auth.json",
);
await writeFile(destination, current, { mode: 0o600 });
const decision =
input.binding.provider === "openai"
? await decideCodexAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
})
: await decideGrokAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
});
if (decision !== 10) return;
await secretService(tx).rotate(
ref.secretId,
{ value: refreshed },
{ userId: grant.subjectUserId },
);
await tx
.update(connectionGrants)
.set({ updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
}));
if (subscriptionFile) {
const refreshed = await readFile(authFile, "utf8");
if (refreshed !== value) {
// A quota exchange can hold this company lock for 60 seconds.
// Retry its 30-second acquisition timeout without discarding the
// provider's only copy of a rotated, single-use refresh token.
const writeBack = () => withAccountHomeSecretMutationLock(undefined, input.companyId, () => db.transaction(async (tx) => {
const [grant] = await tx
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, selection.grant.id),
eq(connectionGrants.companyId, input.companyId),
),
)
.for("update");
// A missing or revoked grant blocks the write-back. Among
// active copies, the merge decision below keeps the
// credential with the newest provider freshness field.
if (!grant || grant.status !== "active") return;
const ref = grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
if (!ref) return;
// Lock the referenced secret row for the rest of this
// transaction. The grant-row lock above does not cover it,
// so an authorized rotation of this secret could otherwise
// land between the read and the write below and be
// overwritten by this stale write-back.
await tx
.select({ id: companySecrets.id })
.from(companySecrets)
.where(
and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, input.companyId),
),
)
.for("update");
const current = await aiConnectionService(
tx as unknown as Db,
).credential({ ...selection, grant });
const destination = path.join(
providerHome,
"current-auth.json",
);
await writeFile(destination, current, { mode: 0o600 });
const decision =
input.binding.provider === "openai"
? await decideCodexAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
})
: await decideGrokAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
});
if (decision !== 10) return;
await secretService(tx).rotate(
ref.secretId,
{ value: refreshed },
{ userId: grant.subjectUserId },
);
await tx
.update(connectionGrants)
.set({ updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
}));
for (let attempt = 0; ; attempt++) {
try { await writeBack(); break; }
catch (error) {
if (attempt >= 2 || (error as NodeJS.ErrnoException)?.code !== WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE) throw error;
}
}
}
} finally {
if (home) await rm(home, { recursive: true, force: true });
}
// Preserve the private home on any failed write-back, including an
// exhausted lock retry or database failure. The caller can retry the
// same cleanup; only a committed save or an intentional discard
// (revoked grant, older/different identity) permits deletion.
if (home) await rm(home, { recursive: true, force: true });
},
};
} catch (error) {
@@ -1,4 +1,3 @@
import { FinancialEventEntry } from "./FinancialEventEntry";
import type { FinanceEvent } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,13 +11,11 @@ import {
interface FinanceTimelineCardProps {
rows: FinanceEvent[];
companyId?: string;
emptyMessage?: string;
}
export function FinanceTimelineCard({
rows,
companyId,
emptyMessage = "No financial events recorded in this period. Charges and credits appear after they are imported or recorded; agent runs do not add them automatically.",
}: FinanceTimelineCardProps) {
return (
@@ -26,7 +23,6 @@ export function FinanceTimelineCard({
<CardHeader className="px-4 pt-4 pb-1">
<CardTitle className="text-base">Recent financial events</CardTitle>
<CardDescription>Provider charges, subscriptions, fees, and credits. Reported separately from run-cost estimates.</CardDescription>
{companyId && <FinancialEventEntry companyId={companyId} />}
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4 pt-3">
{rows.length === 0 ? (
+9 -1
View File
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { formatCents, visibleRunCostUsd } from "./utils";
import { formatCents, visibleRunCostUsd, visibleRunTokenTotal } from "./utils";
import { computeRange } from "../hooks/useDateRange";
describe("cost presentation", () => {
it("keeps historical inclusive input totals and counts receipt-backed cache hits once", () => {
expect(visibleRunTokenTotal({ provider: "openai", inputTokens: 100, cachedInputTokens: 80, outputTokens: 10 })).toBe(110);
expect(visibleRunTokenTotal({ provider: "openai", inputTokens: 20, cachedInputTokens: 80, outputTokens: 10, accountingReceiptId: "saved-receipt" })).toBe(110);
expect(visibleRunTokenTotal({ input_tokens: 100, cached_input_tokens: 80, output_tokens: 10 })).toBe(110);
expect(visibleRunTokenTotal({ input_tokens: 20, cache_read_input_tokens: 80, output_tokens: 10, accountingReceiptId: "saved-receipt" })).toBe(110);
expect(visibleRunTokenTotal(null)).toBe(0);
});
it("shows the adjusted charge and respects an explicit zero", () => {
expect(visibleRunCostUsd({ costUsd: 3.1, cacheAdjustedCostUsd: 1.5 })).toBe(1.5);
expect(visibleRunCostUsd({ cacheAdjustedCostUsd: 0 }, { costUsd: 5 })).toBe(0);
+16
View File
@@ -179,6 +179,22 @@ function readRunCostUsd(payload: Record<string, unknown> | null): number | null
return null;
}
/** Receipt-backed runs store uncached input separately. Older run snapshots
* retain their original input total (Codex included cached input in it). */
export function visibleRunTokenTotal(usage: Record<string, unknown> | null | undefined): number {
const count = (...keys: string[]) => {
for (const key of keys) {
const value = usage?.[key];
if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, value);
}
return 0;
};
const input = count("inputTokens", "input_tokens");
const output = count("outputTokens", "output_tokens");
const cached = count("cachedInputTokens", "cached_input_tokens", "cache_read_input_tokens");
return input + output + (typeof usage?.accountingReceiptId === "string" && usage.accountingReceiptId.length > 0 ? cached : 0);
}
export function visibleRunCostUsd(
usage: Record<string, unknown> | null,
result: Record<string, unknown> | null = null,
+2 -2
View File
@@ -64,7 +64,7 @@ import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { tryCreateWebSocket } from "../lib/websocket";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { Button } from "@/components/ui/button";
@@ -380,7 +380,7 @@ function runMetrics(run: HeartbeatRun) {
output,
cached,
cost,
totalTokens: input + cached + output,
totalTokens: visibleRunTokenTotal(usage),
provider,
model,
};
+2 -2
View File
@@ -61,7 +61,7 @@ import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { tryCreateWebSocket } from "../lib/websocket";
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { Button } from "@/components/ui/button";
@@ -381,7 +381,7 @@ function runMetrics(run: HeartbeatRun) {
output,
cached,
cost,
totalTokens: input + cached + output,
totalTokens: visibleRunTokenTotal(usage),
provider,
model,
};
+33 -2
View File
@@ -11,7 +11,7 @@ const state = vi.hoisted(() => ({ companyId: "company-1", breadcrumbs: vi.fn() }
const overview = vi.hoisted(() => vi.fn());
const api = vi.hoisted(() => Object.fromEntries([
"summary", "byAgent", "byProject", "byAgentModel", "financeSummary", "financeByBiller",
"financeByKind", "financeEvents", "byProvider", "byBiller", "windowSpend", "quotaWindows",
"financeByKind", "financeEvents", "byProvider", "byBiller", "windowSpend", "quotaWindows", "createFinanceEvent",
].map((key) => [key, vi.fn()])));
vi.mock("../api/costs", () => ({ costsApi: api }));
vi.mock("../api/budgets", () => ({ budgetsApi: { overview, upsertPolicy: vi.fn(), resolveIncident: vi.fn() } }));
@@ -38,7 +38,7 @@ describe.each([
await settle();
}
async function click(text: string) {
const button = [...container.querySelectorAll("button")].find((node) => node.textContent === text);
const button = [...document.querySelectorAll("button")].find((node) => node.textContent === text);
expect(button).toBeDefined();
await act(async () => {
if (button!.getAttribute("role") === "tab") {
@@ -82,6 +82,37 @@ describe.each([
vi.unstubAllGlobals();
});
it("confirms the original uncertain charge after report loading, errors, and tab changes", async () => {
await render();
await click("Finance"); await click("Record or import charges");
for (const [label, value] of [["Provider or biller", "Example"], ["Amount (USD)", "1.25"]]) {
const input = [...document.querySelectorAll("label")].find(node => node.textContent?.startsWith(label))!.querySelector("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
api.createFinanceEvent.mockRejectedValueOnce(new Error("lost response"));
await act(async () => document.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })));
await settle();
const original = api.createFinanceEvent.mock.calls[0][1];
expect(original).toMatchObject({ amountCents: "125.0000000", biller: "Example" });
await click("Cancel");
let rejectReport!: (error: Error) => void;
api.financeEvents.mockImplementationOnce(() => new Promise((_, reject) => { rejectReport = reject; }));
await click("Last 7 Days");
expect(client.getQueryCache().getAll().some(query => query.state.fetchStatus === "fetching")).toBe(true);
await act(async () => rejectReport(new Error("report unavailable"))); await settle();
expect(container.textContent).toContain("Financial events could not be loaded");
await click("Overview"); await click("Finance");
await click("Record or import charges");
expect(document.body.textContent).toContain("Confirm original charge");
api.createFinanceEvent.mockResolvedValueOnce({});
await click("Confirm original charge");
expect(api.createFinanceEvent).toHaveBeenCalledTimes(2);
expect(api.createFinanceEvent.mock.calls[1]).toEqual([state.companyId, original]);
});
it("keeps loaded rows and expanded models through minute ticks, failed reloads, and recovery", async () => {
await render();
expect(container.textContent).toContain("runs: 72 api · 0 sub");
+4 -2
View File
@@ -22,6 +22,7 @@ import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { EmptyState } from "../components/EmptyState";
import { FinanceBillerCard } from "../components/FinanceBillerCard";
import { FinanceKindCard } from "../components/FinanceKindCard";
import { FinancialEventEntry } from "../components/FinancialEventEntry";
import { FinanceTimelineCard } from "../components/FinanceTimelineCard";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -579,6 +580,7 @@ export function Costs({
return (
<div className="space-y-6">
<AccountingHealthPanel companyId={companyId} />
<FinancialEventEntry companyId={companyId} />
{showSummaryChrome ? (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
@@ -905,7 +907,7 @@ export function Costs({
</CardContent>
</Card>
<FinanceTimelineCard companyId={companyId} rows={topFinanceEvents.slice(0, 6)} emptyMessage={financeLoading ? "Loading financial events…" : financeError && !financeData ? "Financial events could not be loaded. Please try again shortly." : "No financial events recorded in this period. Charges and credits appear after they are imported or recorded; agent runs do not add them automatically."} />
<FinanceTimelineCard rows={topFinanceEvents.slice(0, 6)} emptyMessage={financeLoading ? "Loading financial events…" : financeError && !financeData ? "Financial events could not be loaded. Please try again shortly." : "No financial events recorded in this period. Charges and credits appear after they are imported or recorded; agent runs do not add them automatically."} />
</div>
</div>
</>
@@ -1177,7 +1179,7 @@ export function Costs({
)}
</CardContent>
</Card>
<FinanceTimelineCard companyId={companyId} rows={topFinanceEvents} />
<FinanceTimelineCard rows={topFinanceEvents} />
</div>
<FinanceKindCard rows={financeData?.byKind ?? []} />
@@ -30,6 +30,20 @@ describe("Costs story finance actions", () => {
const summary = await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json();
expect(summary).toMatchObject({ debitCents: 50, debitCentsExact: "50.0000000", providerReportedCents: 250, providerReportedCentsExact: "250.0000000", eventCount: 2 });
});
it("excludes provider reports from charge groups while keeping them in the timeline and report totals", async () => {
const fixture = createCostsFinanceFixtures("preview");
await fixture("accounting/provider-costs/import", request("accounting/provider-costs/import", { provider: "openai", accountId: "preview-account", scopeIds: ["preview-project"], secretId: "00000000-0000-4000-8000-000000000999", from: "2026-01-01", to: "2026-01-02" }));
for (const endpoint of ["costs/finance-by-biller", "costs/finance-by-kind"]) expect(await (await fixture(endpoint, request(endpoint)))!.json()).toEqual([]);
expect(await (await fixture("costs/finance-events", request("costs/finance-events")))!.json()).toHaveLength(1);
expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()).toMatchObject({ debitCents: 0, providerReportedCents: 250, eventCount: 1 });
await fixture("finance-events", request("finance-events", { ...charge, biller: "openai", eventKind: "inference_charge" }));
for (const endpoint of ["costs/finance-by-biller", "costs/finance-by-kind"]) {
const rows = await (await fixture(endpoint, request(endpoint)))!.json();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ debitCents: 125, providerReportedCents: 0, eventCount: 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"]]) {
+3 -2
View File
@@ -40,10 +40,11 @@ export function createCostsFinanceFixtures(companyId: string) {
if (resource === "costs/finance-events") return Response.json(visible);
if (resource === "costs/finance-by-biller" || resource === "costs/finance-by-kind") {
const byBiller = resource.endsWith("biller");
const keys = [...new Set(visible.map(row => JSON.stringify([row.currency, byBiller ? row.biller : row.eventKind])))];
const charges = visible.filter(row => row.metadataJson?.source !== "provider_cost_report");
const keys = [...new Set(charges.map(row => JSON.stringify([row.currency, byBiller ? row.biller : row.eventKind])))];
return Response.json(keys.map(encoded => {
const [currency, key] = JSON.parse(encoded) as [string, string];
const rows = visible.filter(row => row.currency === currency && (byBiller ? row.biller : row.eventKind) === key);
const rows = charges.filter(row => row.currency === currency && (byBiller ? row.biller : row.eventKind) === key);
return { ...summary(rows, currency), ...(byBiller ? { biller: key, kindCount: new Set(rows.map(row => row.eventKind)).size } : { eventKind: key, billerCount: new Set(rows.map(row => row.biller)).size }) };
}));
}