Files
PaperClipAI/server/src/routes/costs.ts
T
DottaandClaude Opus 5.5 5a51a7685d fix(budgets): raise_budget_and_resume continues the work its budget stopped, once (#20144)
## 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>
2026-11-08 22:56:28 -06:00

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;
}