import { importProviderDailyCosts } from "../services/provider-billing-import.js"; import { subscriptionPriceSchema, mergeSubscriptionsSchema } from "@paperclipai/shared"; import { subscriptionService } from "../services/subscriptions.js"; import { subscriptionCostReport } from "../services/subscription-report.js"; import { refreshCompanySubscriptions } from "../services/subscription-refresh.js"; import { canManageAiConnections } from "./ai-connections.js"; import { importProviderCostsSchema } from "@paperclipai/shared"; import { accountingIntegrityService } from "../services/accounting-integrity.js"; import { costDataQualityReport } from "../services/cost-data-quality.js"; import { billingReconciliationService } from "../services/billing-reconciliation.js"; import { adjustCostSchema, importBillingInvoiceSchema, repairAccountingSchema, retryAccountingSchema } from "@paperclipai/shared"; import { Router } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { createCostEventSchema, createFinanceEventSchema, normalizeIssueIdentifier, resolveBudgetIncidentSchema, updateBudgetSchema, upsertBudgetPolicySchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { budgetService, costService, financeService, companyService, agentService, issueService, heartbeatService, accessService, } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; import { fetchCompanyQuotaWindows } from "../services/quota-windows.js"; import { badRequest, forbidden } from "../errors.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; const reportDateSchema = z.union([z.iso.datetime({ offset: true }), z.iso.date()]); export function parseCostDateRange(query: Record) { if (query.period !== undefined && query.period !== "all" && query.period !== "month") throw badRequest("invalid 'period'"); if (query.period === "all") { if (query.from !== undefined || query.to !== undefined) throw badRequest("all-time period cannot have date bounds"); return { allTime: true }; } if (query.from !== undefined && !reportDateSchema.safeParse(query.from).success) throw badRequest("invalid 'from' date"); if (query.to !== undefined && !reportDateSchema.safeParse(query.to).success) throw badRequest("invalid 'to' date"); const fromRaw = query.from as string | undefined; const toRaw = query.to as string | undefined; const from = fromRaw ? new Date(fromRaw) : undefined; const to = toRaw ? new Date(toRaw) : undefined; if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date"); if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date"); if (from && to && from > to) throw badRequest("from must not be after to"); return (from || to) ? { from, to } : undefined; } export function parseCostLimit(query: Record) { const raw = query.limit; if (raw == null || raw === "") return 100; const limit = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN; if (!Number.isInteger(limit) || limit <= 0 || limit > 500) { throw badRequest("invalid 'limit' value"); } return limit; } export function costRoutes( db: Db, options: { pluginWorkerManager?: PluginWorkerManager } = {}, ) { const router = Router(); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); const budgetHooks = { cancelWorkForScope: heartbeat.cancelBudgetScopeWork, }; const costs = costService(db, budgetHooks); const finance = financeService(db); const budgets = budgetService(db, budgetHooks); const companies = companyService(db); const agents = agentService(db); const issues = issueService(db); const access = accessService(db); function costReaderActor(req: Parameters[0], companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); return { userId: getActorInfo(req).actorId, readOnly: req.actor.memberships?.some(member => member.companyId === companyId && member.membershipRole === "viewer") ?? false }; } async function subscriptionActor(req: Parameters[0], companyId: string) { return { ...costReaderActor(req, companyId), canManage: await canManageAiConnections(db, req, companyId) }; } router.get("/companies/:companyId/costs/data-quality", async (req, res) => { const companyId = req.params.companyId as string; const actor = costReaderActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; res.json(await costDataQualityReport(db, companyId, !actor.readOnly, parseCostDateRange(req.query))); }); router.post("/companies/:companyId/costs/retry-accounting", validate(retryAccountingSchema), async (req, res) => { const companyId = req.params.companyId as string; const actor = costReaderActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; if (actor.readOnly) throw forbidden("Viewers cannot retry cost recording"); res.json(await accountingIntegrityService(db, budgetHooks).retry(companyId, req.body.runId, actor.userId)); }); router.get("/companies/:companyId/costs/subscriptions", async (req, res) => { const companyId = req.params.companyId as string; const actor = await subscriptionActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; res.json(await subscriptionCostReport(db, companyId, actor, parseCostDateRange(req.query))); }); router.post("/companies/:companyId/costs/subscriptions/refresh", async (req, res) => { const companyId = req.params.companyId as string; const actor = await subscriptionActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; if (actor.readOnly) throw forbidden("Viewers cannot refresh subscription accounts"); void refreshCompanySubscriptions(db, companyId, actor.userId); res.status(202).json({ status: "accepted" }); }); router.patch("/companies/:companyId/costs/subscriptions/:subscriptionId", validate(subscriptionPriceSchema), async (req, res) => { const companyId = req.params.companyId as string; const actor = await subscriptionActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const id = z.string().uuid().safeParse(req.params.subscriptionId); if (!id.success) throw badRequest("Invalid subscription ID"); res.json(await subscriptionService(db).updatePrice(companyId, id.data, req.body, actor)); }); router.post("/companies/:companyId/costs/subscriptions/:subscriptionId/link", validate(mergeSubscriptionsSchema), async (req, res) => { const companyId = req.params.companyId as string; const actor = await subscriptionActor(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const id = z.string().uuid().safeParse(req.params.subscriptionId); if (!id.success) throw badRequest("Invalid subscription ID"); res.json(await subscriptionService(db).merge(companyId, id.data, req.body.targetId, req.body.expectedRevision, req.body.targetRevision, actor)); }); async function resolveIssueByRef(rawId: string) { const identifier = normalizeIssueIdentifier(rawId); if (identifier) { return issues.getByIdentifier(identifier); } return issues.getById(rawId); } async function assertCompanyCostReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ actor: req.actor, action: "company_scope:read", resource: { type: "company", companyId }, }); if (decision.allowed) return true; res.status(403).json({ error: "Costs are outside this actor's authorization boundary" }); return false; } async function assertIssueCostReadAllowed(req: Parameters[0], res: any, issue: { id: string; companyId: string; projectId: string | null; parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; status: string; }) { const decision = await access.decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status, }, }); if (decision.allowed) return true; res.status(403).json({ error: "Issue costs are outside this actor's authorization boundary" }); return false; } router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) { res.status(403).json({ error: "Agent can only report its own costs" }); return; } const actor = getActorInfo(req); const event = await costs.createEvent(companyId, { ...req.body, occurredAt: new Date(req.body.occurredAt), }, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId }); res.status(201).json(event); }); router.post("/companies/:companyId/finance-events", validate(createFinanceEventSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertBoard(req); const actor = getActorInfo(req); const event = await finance.createEvent(companyId, { ...req.body, occurredAt: new Date(req.body.occurredAt), }, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId }); res.status(201).json(event); }); router.get("/companies/:companyId/costs/summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const summary = await costs.summary(companyId, range); res.json(summary); }); router.get("/issues/:id/cost-summary", async (req, res) => { const rawId = req.params.id as string; const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found"); if (!issue) return; if (!(await assertIssueCostReadAllowed(req, res, issue))) return; const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1"; const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot }); res.json(summary); }); router.get("/companies/:companyId/costs/by-user", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); res.json(await costs.byUser(companyId, range)); }); router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await costs.byAgent(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await costs.byAgentModel(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/by-provider", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await costs.byProvider(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/by-biller", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await costs.byBiller(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/finance-summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const summary = await finance.summary(companyId, range); res.json(summary); }); router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await finance.byBiller(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await finance.byKind(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/finance-events", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const limit = parseCostLimit(req.query); const rows = await finance.list(companyId, range, limit); res.json(rows); }); router.get("/companies/:companyId/costs/window-spend", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const rows = await costs.windowSpend(companyId); res.json(rows); }); router.get("/companies/:companyId/costs/quota-windows", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertBoard(req); // validate companyId resolves to a real company so the "__none__" sentinel // and any forged ids are rejected before we touch provider credentials const company = await companies.getById(companyId); if (!company) { res.status(404).json({ error: "Company not found" }); return; } const results = await fetchCompanyQuotaWindows(db, companyId, getActorInfo(req).actorId); res.json(results); }); router.get("/companies/:companyId/budgets/overview", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const overview = await budgets.overview(companyId); res.json(overview); }); router.post( "/companies/:companyId/budgets/policies", validate(upsertBudgetPolicySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const summary = await budgets.upsertPolicy(companyId, req.body, req.actor.userId ?? "board"); res.json(summary); }, ); router.post( "/companies/:companyId/budget-incidents/:incidentId/resolve", validate(resolveBudgetIncidentSchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; const incidentId = req.params.incidentId as string; assertCompanyAccess(req, companyId); const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board"); res.json(incident); }, ); router.get("/companies/:companyId/costs/by-project", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return; const range = parseCostDateRange(req.query); const rows = await costs.byProject(companyId, range); res.json(rows); }); router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); await budgets.upsertPolicy( companyId, { scopeType: "company", scopeId: companyId, amount: req.body.budgetMonthlyCents, isActive: req.body.budgetMonthlyCents > 0, windowKind: "calendar_month_utc", }, req.actor.userId ?? "board", ); res.json(await companies.getById(companyId)); }); router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => { const agentId = req.params.agentId as string; const agent = await getAccessibleResource(req, res, agents.getById(agentId), "Agent not found"); if (!agent) return; assertBoard(req); await budgets.upsertPolicy( agent.companyId, { scopeType: "agent", scopeId: agent.id, amount: req.body.budgetMonthlyCents, isActive: req.body.budgetMonthlyCents > 0, windowKind: "calendar_month_utc", }, req.actor.type === "board" ? req.actor.userId ?? "board" : null, ); res.json(await agents.getById(agentId)); }); const integrity = accountingIntegrityService(db, budgetHooks); const billing = billingReconciliationService(db, budgetHooks); // Operational accounting evidence and repairs are board-only. These payloads // can contain company-wide billing identifiers even for an agent's own run. router.use("/companies/:companyId/accounting", (req, _res, next) => { assertCompanyAccess(req, req.params.companyId as string); assertBoard(req); next(); }); router.post("/companies/:companyId/accounting/provider-costs/import", (req, _res, next) => { // Unlike ledger inspection, this operation discloses a stored credential // to a provider. Ordinary company membership does not authorize that use. const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertBoard(req); const membership = req.actor.memberships?.find((item) => item.companyId === companyId); if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin && !(membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole)))) { throw forbidden("Company admin access required to import provider billing."); } next(); }, validate(importProviderCostsSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); assertBoard(req); const result = await importProviderDailyCosts(db, companyId, req.body, getActorInfo(req).actorId); res.json(result); }); router.get("/companies/:companyId/accounting/health", async (req, res) => res.json(await integrity.health(req.params.companyId as string))); router.get("/companies/:companyId/accounting/inspect", async (req, res) => res.json(await integrity.inspect(req.params.companyId as string))); router.post("/companies/:companyId/accounting/repair", validate(repairAccountingSchema), async (req, res) => res.json(await integrity.repair(req.params.companyId as string, req.body.fingerprint, req.body.reason, getActorInfo(req).actorId))); router.post("/companies/:companyId/accounting/retry", validate(retryAccountingSchema), async (req, res) => res.json(await integrity.retry(req.params.companyId as string, req.body.runId, getActorInfo(req).actorId))); router.get("/companies/:companyId/accounting/invoices", async (req, res) => res.json(await billing.list(req.params.companyId as string))); router.post("/companies/:companyId/accounting/invoices", validate(importBillingInvoiceSchema), async (req, res) => res.status(201).json(await billing.importInvoice(req.params.companyId as string, req.body, getActorInfo(req).actorId))); router.get("/companies/:companyId/accounting/invoices/:invoiceId", async (req, res) => res.json(await billing.reconcile(req.params.companyId as string, req.params.invoiceId as string))); router.get("/companies/:companyId/accounting/events/:eventId/adjustments", async (req, res) => res.json(await billing.adjustments(req.params.companyId as string, req.params.eventId as string))); router.post("/companies/:companyId/accounting/events/:eventId/adjustments", validate(adjustCostSchema), async (req, res) => res.status(201).json(await billing.adjust(req.params.companyId as string, req.params.eventId as string, req.body, getActorInfo(req).actorId))); return router; }