diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index fc160934d22..be4ac8df20c 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -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. diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index ccc5df5b29b..30fa4587adf 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -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> | undefined; + let cleaning: Promise | undefined; + let lock: ReturnType | 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" }); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index b199dbc2bcf..85470635bef 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -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) { diff --git a/ui/src/components/FinanceTimelineCard.tsx b/ui/src/components/FinanceTimelineCard.tsx index 11bbffea753..4645c6c571a 100644 --- a/ui/src/components/FinanceTimelineCard.tsx +++ b/ui/src/components/FinanceTimelineCard.tsx @@ -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({ Recent financial events Provider charges, subscriptions, fees, and credits. Reported separately from run-cost estimates. - {companyId && } {rows.length === 0 ? ( diff --git a/ui/src/lib/cost-reporting.test.ts b/ui/src/lib/cost-reporting.test.ts index e5f04e08cce..f4f46c0817e 100644 --- a/ui/src/lib/cost-reporting.test.ts +++ b/ui/src/lib/cost-reporting.test.ts @@ -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); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 89699b4445e..02c1ab32bf8 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -179,6 +179,22 @@ function readRunCostUsd(payload: Record | 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 | 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 | null, result: Record | null = null, diff --git a/ui/src/pages/AgentDetail.production.tsx b/ui/src/pages/AgentDetail.production.tsx index cfcde59e12f..9082602709a 100644 --- a/ui/src/pages/AgentDetail.production.tsx +++ b/ui/src/pages/AgentDetail.production.tsx @@ -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, }; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2e39fbbef0a..e6cedfd38d4 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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, }; diff --git a/ui/src/pages/Costs.refresh.test.tsx b/ui/src/pages/Costs.refresh.test.tsx index 57317a3e511..a2e55f38215 100644 --- a/ui/src/pages/Costs.refresh.test.tsx +++ b/ui/src/pages/Costs.refresh.test.tsx @@ -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"); diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 1d18be2ad66..58176cfc65f 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -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 (
+ {showSummaryChrome ? (
@@ -905,7 +907,7 @@ export function Costs({ - +
@@ -1177,7 +1179,7 @@ export function Costs({ )} - +
diff --git a/ui/storybook/fixtures/costsFinance.test.ts b/ui/storybook/fixtures/costsFinance.test.ts index d65b6dd1c01..7f0422e28ea 100644 --- a/ui/storybook/fixtures/costsFinance.test.ts +++ b/ui/storybook/fixtures/costsFinance.test.ts @@ -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"]]) { diff --git a/ui/storybook/fixtures/costsFinance.ts b/ui/storybook/fixtures/costsFinance.ts index d24eede5871..8e89323edb6 100644 --- a/ui/storybook/fixtures/costsFinance.ts +++ b/ui/storybook/fixtures/costsFinance.ts @@ -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 }) }; })); }