import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { costService, companyService, agentService, logActivity } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { fetchAllQuotaWindows } from "../services/quota-windows.js"; import { badRequest } from "../errors.js"; export function costRoutes(db: Db) { const router = Router(); const costs = costService(db); const companies = companyService(db); const agents = agentService(db); 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 event = await costs.createEvent(companyId, { ...req.body, occurredAt: new Date(req.body.occurredAt), }); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "cost.reported", entityType: "cost_event", entityId: event.id, details: { costCents: event.costCents, model: event.model }, }); res.status(201).json(event); }); function parseDateRange(query: Record) { 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"); return (from || to) ? { from, to } : undefined; } router.get("/companies/:companyId/costs/summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const range = parseDateRange(req.query); const summary = await costs.summary(companyId, range); res.json(summary); }); router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const range = parseDateRange(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); const range = parseDateRange(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); const range = parseDateRange(req.query); const rows = await costs.byProvider(companyId, range); res.json(rows); }); router.get("/companies/:companyId/costs/window-spend", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); 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 fetchAllQuotaWindows(); res.json(results); }); router.get("/companies/:companyId/costs/by-project", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const range = parseDateRange(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; const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents }); if (!company) { res.status(404).json({ error: "Company not found" }); return; } await logActivity(db, { companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "company.budget_updated", entityType: "company", entityId: companyId, details: { budgetMonthlyCents: req.body.budgetMonthlyCents }, }); res.json(company); }); router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => { const agentId = req.params.agentId as string; const agent = await agents.getById(agentId); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; } if (req.actor.type === "agent") { if (req.actor.agentId !== agentId) { res.status(403).json({ error: "Agent can only change its own budget" }); return; } } const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents }); if (!updated) { res.status(404).json({ error: "Agent not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId: updated.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, action: "agent.budget_updated", entityType: "agent", entityId: updated.id, details: { budgetMonthlyCents: updated.budgetMonthlyCents }, }); res.json(updated); }); return router; }