diff --git a/ui/storybook/fixtures/costsFinance.test.ts b/ui/storybook/fixtures/costsFinance.test.ts new file mode 100644 index 00000000000..be6d9e52af2 --- /dev/null +++ b/ui/storybook/fixtures/costsFinance.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createCostsFinanceFixtures } from "./costsFinance"; + +const request = (path: string, data?: unknown) => new Request(`https://storybook.example/api/companies/preview/${path}`, data === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data) }); +const charge = { idempotencyKey: "preview-charge", biller: "Example", amountCents: "125", currency: "USD", direction: "debit", eventKind: "platform_fee", occurredAt: new Date().toISOString() }; +describe("Costs story finance actions", () => { + it("records charges locally, deduplicates retries, and updates visible totals", async () => { + const fixture = createCostsFinanceFixtures("preview"); + await fixture("finance-events", request("finance-events", charge)); + await fixture("finance-events", request("finance-events", charge)); + 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: 125, eventCount: 1 }); + expect((await fixture("accounting/health", request("accounting/health")))!.status).toBe(200); + expect(await (await fixture("accounting/inspect", request("accounting/inspect")))!.json()).toMatchObject({ findings: [] }); + expect((await fixture("accounting/repair", request("accounting/repair", {})))!.status).toBe(422); + const remount = createCostsFinanceFixtures("preview"); + expect(await (await remount("costs/finance-events", request("costs/finance-events")))!.json()).toEqual([]); + }); + it("imports and reviews invoices and simulates provider reports without network fallback", async () => { + const fixture = createCostsFinanceFixtures("preview"); + const invoice = { biller: "Example", externalId: "review-invoice", currency: "USD", lines: [{ externalId: "fee", kind: "fee", amountCents: "50", occurredAt: charge.occurredAt }] }; + const saved = await (await fixture("accounting/invoices", request("accounting/invoices", invoice)))!.json(); + await fixture("accounting/invoices", request("accounting/invoices", invoice)); + expect(await (await fixture("accounting/invoices", request("accounting/invoices")))!.json()).toHaveLength(1); + expect(await (await fixture(`accounting/invoices/${saved.id}`, request(`accounting/invoices/${saved.id}`)))!.json()).toMatchObject({ lines: [{ status: "unmatched", amountCents: "50.0000000" }] }); + const secrets = await (await fixture("secrets", request("secrets")))!.json(); + expect(secrets[0].scope).toBe("company"); + const provider = { provider: "openai", accountId: "preview-account", scopeIds: ["preview-project"], secretId: secrets[0].id, from: "2026-01-01", to: "2026-01-02" }; + await fixture("accounting/provider-costs/import", request("accounting/provider-costs/import", provider)); + const summary = await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json(); + expect(summary).toMatchObject({ debitCents: 50, providerReportedCents: 250, eventCount: 2 }); + }); +}); diff --git a/ui/storybook/fixtures/costsFinance.ts b/ui/storybook/fixtures/costsFinance.ts new file mode 100644 index 00000000000..08afc72371c --- /dev/null +++ b/ui/storybook/fixtures/costsFinance.ts @@ -0,0 +1,79 @@ +import { createFinanceEventSchema, importBillingInvoiceSchema, importProviderCostsSchema, type BillingReconciliation, type FinanceEvent } from "@paperclipai/shared"; + +/** In-memory, per-mount data. Preview actions never contact billing providers. */ +export function createCostsFinanceFixtures(companyId: string) { + const events: FinanceEvent[] = []; + const reports: BillingReconciliation[] = []; + const createdAt = new Date(); + let sequence = 0; + const id = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const summary = (all: FinanceEvent[], currency = "USD") => { + const matching = all.filter(row => row.currency === currency); + const providerReportedCents = matching.filter(row => row.metadataJson?.source === "provider_cost_report").reduce((sum, row) => sum + row.amountCents, 0); + const rows = matching.filter(row => row.metadataJson?.source !== "provider_cost_report"); + const debitCents = rows.filter(row => row.direction === "debit").reduce((sum, row) => sum + row.amountCents, 0); + const creditCents = rows.filter(row => row.direction === "credit").reduce((sum, row) => sum + row.amountCents, 0); + return { currency, providerReportedCents, debitCents, creditCents, netCents: debitCents - creditCents, estimatedDebitCents: 0, eventCount: matching.length }; + }; + function record(raw: unknown) { + const input = createFinanceEventSchema.parse(raw); + const existing = events.find(row => row.idempotencyKey === input.idempotencyKey); + if (existing) return existing; + const event: FinanceEvent = { + id: id(), companyId, agentId: null, issueId: null, projectId: null, goalId: null, + heartbeatRunId: null, costEventId: null, billingCode: null, provider: null, + executionAdapterType: null, pricingTier: null, region: null, model: null, quantity: null, unit: null, + 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, + }; + events.unshift(event); + return event; + } + return async (resource: string, request: Request): Promise => { + const url = new URL(request.url); + const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); + const visible = events.filter(row => (!from || row.occurredAt >= new Date(from)) && (!to || row.occurredAt <= new Date(`${to.slice(0, 10)}T23:59:59.999Z`))); + 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-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])))]; + 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); + 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 }) }; + })); + } + if (resource === "finance-events" && request.method === "POST") return Response.json(record(await request.json())); + if (resource === "accounting/health") return Response.json({ companyId, pendingRunCount: 0, unpricedEventCount: 0, pendingCancellationCount: 0, heldReservationCents: "0", oldestPendingAt: null, items: [] }); + if (resource === "accounting/inspect") return Response.json({ companyId, fingerprint: "storybook-ledger", checkedAt: createdAt.toISOString(), findings: [] }); + if (resource === "accounting/invoices") { + if (request.method === "GET") return Response.json(reports.map(report => report.invoice)); + const input = importBillingInvoiceSchema.parse(await request.json()); + let report = reports.find(row => row.invoice.biller === input.biller && row.invoice.externalId === input.externalId); + if (!report) { + const invoice = { id: id(), companyId, biller: input.biller, externalId: input.externalId, currency: input.currency, createdAt: createdAt.toISOString() }; + report = { invoice, lines: input.lines.map(line => ({ id: id(), externalId: line.externalId, kind: line.kind, amountCents: line.amountCents, status: "unmatched", matchedEventId: null, recordedCents: null, differenceCents: null })) }; + reports.push(report); + for (const line of input.lines) record({ idempotencyKey: `preview-invoice:${invoice.id}:${line.externalId}`, externalInvoiceId: invoice.externalId, biller: input.biller, currency: input.currency, amountCents: line.amountCents, occurredAt: line.occurredAt, direction: line.kind === "credit" ? "credit" : "debit", eventKind: line.kind === "credit" ? "credit_refund" : line.kind === "fee" ? "platform_fee" : "inference_charge", description: `Preview invoice ${input.externalId}` }); + } + return Response.json(report.invoice); + } + if (resource.startsWith("accounting/invoices/")) { + const report = reports.find(row => row.invoice.id === resource.split("/")[2]); + return report ? Response.json(report) : Response.json({ error: "Preview invoice not found." }, { status: 404 }); + } + if (resource === "secrets") return Response.json([{ id: "00000000-0000-4000-8000-000000000999", name: "Preview admin credential (simulated)", status: "active", scope: "company" }]); + if (resource === "accounting/provider-costs/import") { + const input = importProviderCostsSchema.parse(await request.json()); + const before = events.length; + record({ idempotencyKey: `preview-report:${input.provider}:${input.accountId}:${input.from}:${input.to}`, biller: input.provider, amountCents: "250", currency: "USD", direction: "debit", eventKind: "inference_charge", metadataJson: { source: "provider_cost_report" }, occurredAt: `${input.from}T00:00:00.000Z`, description: "Simulated provider report; no provider was contacted." }); + return Response.json({ daysRead: (Date.parse(input.to) - Date.parse(input.from)) / 86400000, eventsCreated: events.length - before }); + } + // Keep unsupported accounting mutations local too; never leak to a dev API. + if (resource.startsWith("accounting/") || resource === "finance-events") return Response.json({ error: "This action is not available in the preview fixture." }, { status: 422 }); + return null; + }; +} diff --git a/ui/storybook/stories/costs.stories.tsx b/ui/storybook/stories/costs.stories.tsx index a4bc191facc..8c8f0acfe17 100644 --- a/ui/storybook/stories/costs.stories.tsx +++ b/ui/storybook/stories/costs.stories.tsx @@ -4,6 +4,8 @@ import type { CostByAgent, CostByAgentModel, CostByProject } from "@paperclipai/ import { Costs } from "@/pages/Costs"; import { useCompany } from "@/context/CompanyContext"; +import { createCostsFinanceFixtures } from "../fixtures/costsFinance"; + const companyId = "company-storybook"; const base = { agentStatus: "idle", inputTokens: 240_000, cachedInputTokens: 2_400_000, @@ -43,19 +45,20 @@ function CostsPreview() { const [ready, setReady] = useState(false); useEffect(() => { const previous = window.fetch; + const finance = createCostsFinanceFixtures(companyId); window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); const prefix = `/api/companies/${companyId}/`; if (!url.pathname.startsWith(prefix)) return previous(input, init); const resource = url.pathname.slice(prefix.length); + const fixture = await finance(resource, new Request(input instanceof Request ? input : url, init)); + if (fixture) return fixture; if (resource === "costs/summary") return Response.json({ companyId, spendCents: 42345, budgetCents: 100000, utilizationPercent: 42.35, eventCount: 96, estimatedEventCount: 10, unpricedEventCount: 0, pendingRunCount: 0, pricingComplete: true }); if (resource === "costs/by-agent") return Response.json(agents); if (resource === "costs/by-agent-model") return Response.json(models); if (resource === "costs/by-project") return Response.json(projects); - if (resource === "costs/finance-summary") return Response.json({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0, currencies: [] }); if (resource.startsWith("costs/")) return Response.json([]); if (resource === "budgets/overview") return Response.json({ companyId, policies: [], activeIncidents: [], pausedAgentCount: 0, pausedProjectCount: 0, pendingApprovalCount: 0 }); - if (resource === "accounting/health") return Response.json({ pendingRunCount: 0, unpricedEventCount: 0, pendingCancellationCount: 0, heldReservationCents: "0", oldestPendingAt: null, items: [] }); return previous(input, init); }; setSelectedCompanyId(companyId); @@ -71,7 +74,7 @@ const meta = { component: CostsPreview, parameters: { layout: "fullscreen", - docs: { description: { component: "The shared Costs page as embedded in the default streamlined UI, with illustrative amounts. Codie has only estimates, Fry is partially estimated, Bender has only reported charges, and Leela uses a subscription. Expand an agent to inspect model labels. Run costs are attributed to Agent orchestration (Bender), Cost reporting (Codie and Fry), and Research (Leela); project costs and tokens reconcile with the agent totals." } }, + docs: { description: { component: "The shared Costs page as embedded in the default streamlined UI, with illustrative amounts. Codie has only estimates, Fry is partially estimated, Bender has only reported charges, and Leela uses a subscription. Expand an agent to inspect model labels. Run costs are attributed to Agent orchestration (Bender), Cost reporting (Codie and Fry), and Research (Leela); project costs and tokens reconcile with the agent totals. Finance entry, invoice review, and provider report imports use per-mount in-memory fixtures; they never contact a provider. Imported invoice lines remain unmatched because the preview does not contain a real ledger." } }, }, } satisfies Meta; export default meta;