feat(costs): add billing, quota, and budget control plane

This commit is contained in:
Dotta
2026-03-14 22:00:12 -05:00
parent 656b4659fc
commit 76e6cc08a6
91 changed files with 22406 additions and 769 deletions

View File

@@ -23,6 +23,7 @@ import {
agentService,
accessService,
approvalService,
budgetService,
heartbeatService,
issueApprovalService,
issueService,
@@ -57,6 +58,7 @@ export function agentRoutes(db: Db) {
const svc = agentService(db);
const access = accessService(db);
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
@@ -941,6 +943,19 @@ export function agentRoutes(db: Db) {
details: { name: agent.name, role: agent.role },
});
if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
companyId,
{
scopeType: "agent",
scopeId: agent.id,
amount: agent.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
actor.actorType === "user" ? actor.actorId : null,
);
}
res.status(201).json(agent);
});

View File

@@ -9,7 +9,13 @@ import {
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
import {
accessService,
budgetService,
companyPortabilityService,
companyService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) {
@@ -17,6 +23,7 @@ export function companyRoutes(db: Db) {
const svc = companyService(db);
const portability = companyPortabilityService(db);
const access = accessService(db);
const budgets = budgetService(db);
router.get("/", async (req, res) => {
assertBoard(req);
@@ -122,6 +129,18 @@ export function companyRoutes(db: Db) {
entityId: company.id,
details: { name: company.name },
});
if (company.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
company.id,
{
scopeType: "company",
scopeId: company.id,
amount: company.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
}
res.status(201).json(company);
});

View File

@@ -1,8 +1,21 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
import {
createCostEventSchema,
createFinanceEventSchema,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { costService, companyService, agentService, logActivity } from "../services/index.js";
import {
budgetService,
costService,
financeService,
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";
@@ -10,6 +23,8 @@ import { badRequest } from "../errors.js";
export function costRoutes(db: Db) {
const router = Router();
const costs = costService(db);
const finance = financeService(db);
const budgets = budgetService(db);
const companies = companyService(db);
const agents = agentService(db);
@@ -42,6 +57,36 @@ export function costRoutes(db: Db) {
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 event = await finance.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: "finance_event.reported",
entityType: "finance_event",
entityId: event.id,
details: {
amountCents: event.amountCents,
biller: event.biller,
eventKind: event.eventKind,
direction: event.direction,
},
});
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;
@@ -52,6 +97,16 @@ export function costRoutes(db: Db) {
return (from || to) ? { from, to } : undefined;
}
function parseLimit(query: Record<string, unknown>) {
const raw = query.limit as string | undefined;
if (!raw) return 100;
const limit = Number.parseInt(raw, 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
}
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -84,6 +139,47 @@ export function costRoutes(db: Db) {
res.json(rows);
});
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(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);
const range = parseDateRange(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);
const range = parseDateRange(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);
const range = parseDateRange(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);
const range = parseDateRange(req.query);
const limit = parseLimit(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);
@@ -106,6 +202,38 @@ export function costRoutes(db: Db) {
res.json(results);
});
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
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);
@@ -133,6 +261,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
});
await budgets.upsertPolicy(
companyId,
{
scopeType: "company",
scopeId: companyId,
amount: req.body.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
res.json(company);
});
@@ -169,6 +308,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
});
await budgets.upsertPolicy(
updated.companyId,
{
scopeType: "agent",
scopeId: updated.id,
amount: updated.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
);
res.json(updated);
});

View File

@@ -921,6 +921,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
assertCompanyAccess(req, issue.companyId);
if (issue.projectId) {
const project = await projectsSvc.getById(issue.projectId);
if (project?.pausedAt) {
res.status(409).json({
error:
project.pauseReason === "budget"
? "Project is paused because its budget hard-stop was reached"
: "Project is paused",
});
return;
}
}
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only checkout as itself" });
return;