mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
ci: include verified Storybook accounting fixtures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
72a9e46b15
3 files changed
+118
-3
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Response | null> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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<typeof CostsPreview>;
|
||||
export default meta;
|
||||
|
||||
Reference in new issue
Block a user