Files
paperclip/server/src/routes/costs.ts
Sai Shankar 9d21380699 feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries
- add byAgentModel endpoint and expandable per-agent model sub-rows in the spend tab
- validate date range inputs with isNaN + badRequest to return HTTP 400 on bad input
- move CostByProject from a local api/costs.ts definition into packages/shared types
- gate providerData query on mainTab === providers, consistent with weekData/windowData/quotaData
- fix byProject range filter from finishedAt to startedAt, consistent with byProvider runs query
- fix WHAM used_percent threshold from <= 1 to < 1 to avoid misclassifying 1% usage as 100%
- replace inline opacity style with tailwind bg-primary/85 class in ProviderQuotaCard
- reset expandedAgents set when company or date range changes
- sort agent model sub-rows by cost descending in ui memo
2026-03-16 15:08:54 -05:00

177 lines
6.1 KiB
TypeScript

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<string, unknown>) {
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;
}