feat(costs): add billing, quota, and budget control plane
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user