mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A budget hard stop cancels the scope's running work; the board resolves it with Raise budget and resume > - In the restart-chaos H1 scenario, the cancelled tasks did not continue after resume and each needed a manual Retry > - Resume is the board's explicit decision to continue, so the work it stopped should continue once, as if the board clicked Retry > - This pull request files that Retry automatically, keyed by the cancelled run, once the run's stop has settled > - The benefit is that a budget raise restores the work without manual Retries, without repeating completed work, and without charging the run's attempt budget ## Linked Issues or Issue Description Refs #20143 (bug B; this PR builds on it), #20113 (bug A), #20101 (superseded: split into #20143 and this PR) **What happened?** Restart-chaos waves w-20261108T195944-s42 and w-20261108T210118-s9802 (H1). After `raise_budget_and_resume`, CHA-3's budget-cancelled run (enforcement stop) and CHA-1's run (agent lifecycle pause from the budget hold) stayed cancelled until someone clicked Retry. **Expected behavior** Resuming continues each task its budget stopped, exactly once, with no attempt charged, an activity entry, and no repeat of work that already continued. A run a person stopped stays stopped. **Steps to reproduce** Set a budget below spend while a task runs; let the stop cancel the run; resolve with Raise budget and resume. Before this change, no new run is queued. ## What Changed - New table `budget_resume_claims` (migration 0451). One row per cancelled run, outside the run's result JSON, so a Stop that settles during the resume cannot erase it. - `markBudgetCancelledWorkForResume` (called by `resolveIncident` before the incident closes): claims every task run its budget stopped since the incident opened. That covers runs budget enforcement fenced, including stops still in flight, and runs the agent lifecycle pause stopped for this budget (`pauseStop`, carried from the lifecycle operation's `pauseCause` so a resume before the Stop keeps the link). Runs a person stopped are excluded. Two indexed passes led by `(company_id, status)`. - `deliverBudgetResumeContinuations`: once a claimed run's stop settles, files the resolving board member's Retry (`requestTaskRetry`, idempotency key `budget-resume:<run>`, with the member's session source and the chat Retry callback). The Retry path checks, under the task lock, that the run is still the task's latest and the task is open, so continued or completed work is never repeated. Claim settlement and its activity entry commit together; retryable refusals and errors retry. - Delivery runs in the always-on budget-enforcement worker after each pass's Stop delivery. Its schedule includes deliverable claims and the earliest stale-claim expiry. The execution-continuation worker is a backstop. - `agent.paused` / `agent.resumed` logging is in #20113. `budget.cancelled_work_resume_requested` (incident) and `budget.cancelled_work_continued` / `_not_continued` (task) are recorded here. ## Verification - New `budget-resume-continuation.test.ts` (Postgres, real `resolveIncident` → claims → `requestTaskRetry` → real `resumeTaskRetry` into a queued run): - each task continues once, including a blocked task; repeat delivery or resolve saves nothing new; - a stop still in flight at resume, whose terminal write replaces the result JSON, is still continued; - a 30-task scope is fully continued; - a lifecycle-pause stop caused by the budget is continued, and one caused by a manual pause is not; - continued work, done tasks, a run that finished before its stop, and person-stopped runs (including ones enforcement also fenced) are not continued. - `lifecycle.test.ts`: the pause records its cause on the operation and on stopped runs, including when the budget is resumed before the pause's Stop. `budget-enforcement-worker.test.ts`: delivery after Stop delivery, and a wake at claim expiry. - 287 tests across the affected suites passed. Server typecheck (`typecheck-layers`, low-memory): no errors. - Not run: the H1 chaos rerun (Phase 3 runs it after merge). ## Risks - Resume now continues budget-stopped tasks automatically. A board that wants a task stopped can Stop it again; person-stopped runs are never continued. - A new table and one index (migration 0451). > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, test execution). Extended reasoning enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
21 KiB
TypeScript
475 lines
21 KiB
TypeScript
import { importProviderDailyCosts } from "../services/provider-billing-import.js";
|
|
import { subscriptionPriceSchema, mergeSubscriptionsSchema } from "@paperclipai/shared";
|
|
import { subscriptionService } from "../services/subscriptions.js";
|
|
import { subscriptionCostReport } from "../services/subscription-report.js";
|
|
import { refreshCompanySubscriptions } from "../services/subscription-refresh.js";
|
|
import { canManageAiConnections } from "./ai-connections.js";
|
|
import { importProviderCostsSchema } from "@paperclipai/shared";
|
|
import { accountingIntegrityService } from "../services/accounting-integrity.js";
|
|
import { costDataQualityReport } from "../services/cost-data-quality.js";
|
|
import { billingReconciliationService } from "../services/billing-reconciliation.js";
|
|
import { adjustCostSchema, importBillingInvoiceSchema, repairAccountingSchema, retryAccountingSchema } from "@paperclipai/shared";
|
|
import { Router } from "express";
|
|
import { z } from "zod";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
createCostEventSchema,
|
|
createFinanceEventSchema,
|
|
normalizeIssueIdentifier,
|
|
resolveBudgetIncidentSchema,
|
|
updateBudgetSchema,
|
|
upsertBudgetPolicySchema,
|
|
} from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import {
|
|
budgetService,
|
|
costService,
|
|
financeService,
|
|
companyService,
|
|
agentService,
|
|
issueService,
|
|
heartbeatService,
|
|
accessService,
|
|
} from "../services/index.js";
|
|
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
|
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
|
|
import { badRequest, forbidden } from "../errors.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
|
|
const reportDateSchema = z.union([z.iso.datetime({ offset: true }), z.iso.date()]);
|
|
|
|
export function parseCostDateRange(query: Record<string, unknown>) {
|
|
if (query.period !== undefined && query.period !== "all" && query.period !== "month") throw badRequest("invalid 'period'");
|
|
if (query.period === "all") {
|
|
if (query.from !== undefined || query.to !== undefined) throw badRequest("all-time period cannot have date bounds");
|
|
return { allTime: true };
|
|
}
|
|
if (query.from !== undefined && !reportDateSchema.safeParse(query.from).success) throw badRequest("invalid 'from' date");
|
|
if (query.to !== undefined && !reportDateSchema.safeParse(query.to).success) throw badRequest("invalid 'to' date");
|
|
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");
|
|
if (from && to && from > to) throw badRequest("from must not be after to");
|
|
return (from || to) ? { from, to } : undefined;
|
|
}
|
|
|
|
export function parseCostLimit(query: Record<string, unknown>) {
|
|
const raw = query.limit;
|
|
if (raw == null || raw === "") return 100;
|
|
const limit = typeof raw === "number" || typeof raw === "string" ? Number(raw) : Number.NaN;
|
|
if (!Number.isInteger(limit) || limit <= 0 || limit > 500) {
|
|
throw badRequest("invalid 'limit' value");
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
export function costRoutes(
|
|
db: Db,
|
|
options: { pluginWorkerManager?: PluginWorkerManager } = {},
|
|
) {
|
|
const router = Router();
|
|
const heartbeat = heartbeatService(db, {
|
|
pluginWorkerManager: options.pluginWorkerManager,
|
|
});
|
|
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);
|
|
const issues = issueService(db);
|
|
const access = accessService(db);
|
|
|
|
function costReaderActor(req: Parameters<typeof assertCompanyAccess>[0], companyId: string) {
|
|
assertBoard(req);
|
|
assertCompanyAccess(req, companyId);
|
|
return { userId: getActorInfo(req).actorId,
|
|
readOnly: req.actor.memberships?.some(member => member.companyId === companyId && member.membershipRole === "viewer") ?? false };
|
|
}
|
|
|
|
async function subscriptionActor(req: Parameters<typeof assertCompanyAccess>[0], companyId: string) {
|
|
return { ...costReaderActor(req, companyId), canManage: await canManageAiConnections(db, req, companyId) };
|
|
}
|
|
|
|
router.get("/companies/:companyId/costs/data-quality", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = costReaderActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
res.json(await costDataQualityReport(db, companyId, !actor.readOnly, parseCostDateRange(req.query)));
|
|
});
|
|
router.post("/companies/:companyId/costs/retry-accounting", validate(retryAccountingSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = costReaderActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
if (actor.readOnly) throw forbidden("Viewers cannot retry cost recording");
|
|
res.json(await accountingIntegrityService(db, budgetHooks).retry(companyId, req.body.runId, actor.userId));
|
|
});
|
|
|
|
router.get("/companies/:companyId/costs/subscriptions", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = await subscriptionActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
res.json(await subscriptionCostReport(db, companyId, actor, parseCostDateRange(req.query)));
|
|
});
|
|
router.post("/companies/:companyId/costs/subscriptions/refresh", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = await subscriptionActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
if (actor.readOnly) throw forbidden("Viewers cannot refresh subscription accounts");
|
|
void refreshCompanySubscriptions(db, companyId, actor.userId);
|
|
res.status(202).json({ status: "accepted" });
|
|
});
|
|
router.patch("/companies/:companyId/costs/subscriptions/:subscriptionId", validate(subscriptionPriceSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = await subscriptionActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const id = z.string().uuid().safeParse(req.params.subscriptionId);
|
|
if (!id.success) throw badRequest("Invalid subscription ID");
|
|
res.json(await subscriptionService(db).updatePrice(companyId, id.data, req.body, actor));
|
|
});
|
|
router.post("/companies/:companyId/costs/subscriptions/:subscriptionId/link", validate(mergeSubscriptionsSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const actor = await subscriptionActor(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const id = z.string().uuid().safeParse(req.params.subscriptionId);
|
|
if (!id.success) throw badRequest("Invalid subscription ID");
|
|
res.json(await subscriptionService(db).merge(companyId, id.data, req.body.targetId, req.body.expectedRevision, req.body.targetRevision, actor));
|
|
});
|
|
|
|
async function resolveIssueByRef(rawId: string) {
|
|
const identifier = normalizeIssueIdentifier(rawId);
|
|
if (identifier) {
|
|
return issues.getByIdentifier(identifier);
|
|
}
|
|
return issues.getById(rawId);
|
|
}
|
|
|
|
async function assertCompanyCostReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "company_scope:read",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Costs are outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function assertIssueCostReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
|
|
id: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
parentId: string | null;
|
|
assigneeAgentId: string | null;
|
|
assigneeUserId: string | null;
|
|
status: string;
|
|
}) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "issue:read",
|
|
resource: {
|
|
type: "issue",
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
parentIssueId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
},
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Issue costs are outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
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 actor = getActorInfo(req);
|
|
const event = await costs.createEvent(companyId, {
|
|
...req.body,
|
|
occurredAt: new Date(req.body.occurredAt),
|
|
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
|
|
|
|
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 actor = getActorInfo(req);
|
|
const event = await finance.createEvent(companyId, {
|
|
...req.body,
|
|
occurredAt: new Date(req.body.occurredAt),
|
|
}, { actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId });
|
|
|
|
res.status(201).json(event);
|
|
});
|
|
|
|
router.get("/companies/:companyId/costs/summary", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(req.query);
|
|
const summary = await costs.summary(companyId, range);
|
|
res.json(summary);
|
|
});
|
|
|
|
router.get("/issues/:id/cost-summary", async (req, res) => {
|
|
const rawId = req.params.id as string;
|
|
const issue = await getAccessibleResource(req, res, resolveIssueByRef(rawId), "Issue not found");
|
|
if (!issue) return;
|
|
if (!(await assertIssueCostReadAllowed(req, res, issue))) return;
|
|
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
|
|
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
|
|
res.json(summary);
|
|
});
|
|
|
|
router.get("/companies/:companyId/costs/by-user", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(req.query);
|
|
res.json(await costs.byUser(companyId, range));
|
|
});
|
|
|
|
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(req.query);
|
|
const limit = parseCostLimit(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);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
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 fetchCompanyQuotaWindows(db, companyId, getActorInfo(req).actorId);
|
|
res.json(results);
|
|
});
|
|
|
|
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
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",
|
|
req.actor.source ?? null);
|
|
res.json(incident);
|
|
},
|
|
);
|
|
|
|
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
|
|
const range = parseCostDateRange(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;
|
|
assertCompanyAccess(req, companyId);
|
|
await budgets.upsertPolicy(
|
|
companyId,
|
|
{
|
|
scopeType: "company",
|
|
scopeId: companyId,
|
|
amount: req.body.budgetMonthlyCents,
|
|
isActive: req.body.budgetMonthlyCents > 0,
|
|
windowKind: "calendar_month_utc",
|
|
},
|
|
req.actor.userId ?? "board",
|
|
);
|
|
|
|
res.json(await companies.getById(companyId));
|
|
});
|
|
|
|
router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => {
|
|
const agentId = req.params.agentId as string;
|
|
const agent = await getAccessibleResource(req, res, agents.getById(agentId), "Agent not found");
|
|
if (!agent) return;
|
|
|
|
assertBoard(req);
|
|
|
|
await budgets.upsertPolicy(
|
|
agent.companyId,
|
|
{
|
|
scopeType: "agent",
|
|
scopeId: agent.id,
|
|
amount: req.body.budgetMonthlyCents,
|
|
isActive: req.body.budgetMonthlyCents > 0,
|
|
windowKind: "calendar_month_utc",
|
|
},
|
|
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
|
|
);
|
|
|
|
res.json(await agents.getById(agentId));
|
|
});
|
|
|
|
const integrity = accountingIntegrityService(db, budgetHooks);
|
|
const billing = billingReconciliationService(db, budgetHooks);
|
|
// Operational accounting evidence and repairs are board-only. These payloads
|
|
// can contain company-wide billing identifiers even for an agent's own run.
|
|
router.use("/companies/:companyId/accounting", (req, _res, next) => {
|
|
assertCompanyAccess(req, req.params.companyId as string); assertBoard(req); next();
|
|
});
|
|
router.post("/companies/:companyId/accounting/provider-costs/import", (req, _res, next) => {
|
|
// Unlike ledger inspection, this operation discloses a stored credential
|
|
// to a provider. Ordinary company membership does not authorize that use.
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
assertBoard(req);
|
|
const membership = req.actor.memberships?.find((item) => item.companyId === companyId);
|
|
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin
|
|
&& !(membership?.status === "active" && ["owner", "admin"].includes(String(membership.membershipRole)))) {
|
|
throw forbidden("Company admin access required to import provider billing.");
|
|
}
|
|
next();
|
|
}, validate(importProviderCostsSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
assertBoard(req);
|
|
const result = await importProviderDailyCosts(db, companyId, req.body, getActorInfo(req).actorId);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/accounting/health", async (req, res) => res.json(await integrity.health(req.params.companyId as string)));
|
|
router.get("/companies/:companyId/accounting/inspect", async (req, res) => res.json(await integrity.inspect(req.params.companyId as string)));
|
|
router.post("/companies/:companyId/accounting/repair", validate(repairAccountingSchema), async (req, res) =>
|
|
res.json(await integrity.repair(req.params.companyId as string, req.body.fingerprint, req.body.reason, getActorInfo(req).actorId)));
|
|
router.post("/companies/:companyId/accounting/retry", validate(retryAccountingSchema), async (req, res) =>
|
|
res.json(await integrity.retry(req.params.companyId as string, req.body.runId, getActorInfo(req).actorId)));
|
|
router.get("/companies/:companyId/accounting/invoices", async (req, res) => res.json(await billing.list(req.params.companyId as string)));
|
|
router.post("/companies/:companyId/accounting/invoices", validate(importBillingInvoiceSchema), async (req, res) =>
|
|
res.status(201).json(await billing.importInvoice(req.params.companyId as string, req.body, getActorInfo(req).actorId)));
|
|
router.get("/companies/:companyId/accounting/invoices/:invoiceId", async (req, res) =>
|
|
res.json(await billing.reconcile(req.params.companyId as string, req.params.invoiceId as string)));
|
|
router.get("/companies/:companyId/accounting/events/:eventId/adjustments", async (req, res) =>
|
|
res.json(await billing.adjustments(req.params.companyId as string, req.params.eventId as string)));
|
|
router.post("/companies/:companyId/accounting/events/:eventId/adjustments", validate(adjustCostSchema), async (req, res) =>
|
|
res.status(201).json(await billing.adjust(req.params.companyId as string, req.params.eventId as string, req.body, getActorInfo(req).actorId)));
|
|
|
|
return router;
|
|
}
|