mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Merge final quota and dashboard review fixes into accounting gate
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
fd1121e649
12 files changed
+232
-82
No files matched your search
@@ -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" });
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]]) {
|
||||
|
||||
@@ -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 }) };
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user