Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: Fix budget incident resolution edge cases Fix agent budget tab routing Fix budget auth and monthly spend rollups Harden budget enforcement and migration startup Add budget tabs and sidebar budget indicators feat(costs): add billing, quota, and budget control plane refactor(quota): move provider quota logic into adapter layer, add unit tests fix(costs): replace non-null map assertions with nullish coalescing, clarify weekData guard fix(costs): guard byProject against duplicate null keys, memoize ProviderQuotaCard row aggregations fix(costs): align byAgent run filter to startedAt, tighten providerTabItems memo deps, stabilize byProject row keys feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows fix(costs): guard routes, fix DST ranges, sync provider state, wire live updates feat(costs): consolidate /usage into /costs with Spend + Providers tabs feat(usage): add subscription quota windows per provider on /usage page address greptile review: per-provider deficit notch, startedAt filter, weekRange refresh, deduplicate providerDisplayName feat(ui): add resource and usage dashboard (/usage route) # Conflicts: # packages/db/src/migration-runtime.ts # packages/db/src/migrations/meta/0031_snapshot.json # packages/db/src/migrations/meta/_journal.json
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,13 +1,35 @@
|
||||
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,
|
||||
heartbeatService,
|
||||
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 heartbeat = heartbeatService(db);
|
||||
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);
|
||||
|
||||
@@ -40,12 +62,56 @@ 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 from = query.from ? new Date(query.from as string) : undefined;
|
||||
const to = query.to ? new Date(query.to as string) : undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -62,6 +128,117 @@ export function costRoutes(db: Db) {
|
||||
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/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);
|
||||
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/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);
|
||||
@@ -73,6 +250,7 @@ export function costRoutes(db: Db) {
|
||||
router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
|
||||
if (!company) {
|
||||
res.status(404).json({ error: "Company not found" });
|
||||
@@ -89,6 +267,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);
|
||||
});
|
||||
|
||||
@@ -100,6 +289,8 @@ export function costRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertCompanyAccess(req, agent.companyId);
|
||||
|
||||
if (req.actor.type === "agent") {
|
||||
if (req.actor.agentId !== agentId) {
|
||||
res.status(403).json({ error: "Agent can only change its own budget" });
|
||||
@@ -125,6 +316,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1032,6 +1032,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