Files
paperclip/server/src/services/budgets.ts
2026-03-16 16:48:13 -05:00

959 lines
31 KiB
TypeScript

import { and, desc, eq, gte, inArray, lt, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
budgetIncidents,
budgetPolicies,
companies,
costEvents,
projects,
} from "@paperclipai/db";
import type {
BudgetIncident,
BudgetIncidentResolutionInput,
BudgetMetric,
BudgetOverview,
BudgetPolicy,
BudgetPolicySummary,
BudgetPolicyUpsertInput,
BudgetScopeType,
BudgetThresholdType,
BudgetWindowKind,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
type ScopeRecord = {
companyId: string;
name: string;
paused: boolean;
pauseReason: "manual" | "budget" | "system" | null;
};
type PolicyRow = typeof budgetPolicies.$inferSelect;
type IncidentRow = typeof budgetIncidents.$inferSelect;
export type BudgetEnforcementScope = {
companyId: string;
scopeType: BudgetScopeType;
scopeId: string;
};
export type BudgetServiceHooks = {
cancelWorkForScope?: (scope: BudgetEnforcementScope) => Promise<void>;
};
function currentUtcMonthWindow(now = new Date()) {
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const start = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0));
return { start, end };
}
function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
if (windowKind === "lifetime") {
return {
start: new Date(Date.UTC(1970, 0, 1, 0, 0, 0, 0)),
end: new Date(Date.UTC(9999, 0, 1, 0, 0, 0, 0)),
};
}
return currentUtcMonthWindow(now);
}
function budgetStatusFromObserved(
observedAmount: number,
amount: number,
warnPercent: number,
): BudgetPolicySummary["status"] {
if (amount <= 0) return "ok";
if (observedAmount >= amount) return "hard_stop";
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
return "ok";
}
function normalizeScopeName(scopeType: BudgetScopeType, name: string) {
if (scopeType === "company") return name;
return name.trim().length > 0 ? name : scopeType;
}
async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: string): Promise<ScopeRecord> {
if (scopeType === "company") {
const row = await db
.select({
companyId: companies.id,
name: companies.name,
status: companies.status,
pauseReason: companies.pauseReason,
pausedAt: companies.pausedAt,
})
.from(companies)
.where(eq(companies.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Company not found");
return {
companyId: row.companyId,
name: row.name,
paused: row.status === "paused" || Boolean(row.pausedAt),
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
};
}
if (scopeType === "agent") {
const row = await db
.select({
companyId: agents.companyId,
name: agents.name,
status: agents.status,
pauseReason: agents.pauseReason,
})
.from(agents)
.where(eq(agents.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Agent not found");
return {
companyId: row.companyId,
name: row.name,
paused: row.status === "paused",
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
};
}
const row = await db
.select({
companyId: projects.companyId,
name: projects.name,
pauseReason: projects.pauseReason,
pausedAt: projects.pausedAt,
})
.from(projects)
.where(eq(projects.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Project not found");
return {
companyId: row.companyId,
name: row.name,
paused: Boolean(row.pausedAt),
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
};
}
async function computeObservedAmount(
db: Db,
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
) {
if (policy.metric !== "billed_cents") return 0;
const conditions = [eq(costEvents.companyId, policy.companyId)];
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
if (policy.scopeType === "project") conditions.push(eq(costEvents.projectId, policy.scopeId));
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
if (policy.windowKind === "calendar_month_utc") {
conditions.push(gte(costEvents.occurredAt, start));
conditions.push(lt(costEvents.occurredAt, end));
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.from(costEvents)
.where(and(...conditions));
return Number(row?.total ?? 0);
}
function buildApprovalPayload(input: {
policy: PolicyRow;
scopeName: string;
thresholdType: BudgetThresholdType;
amountObserved: number;
windowStart: Date;
windowEnd: Date;
}) {
return {
scopeType: input.policy.scopeType,
scopeId: input.policy.scopeId,
scopeName: input.scopeName,
metric: input.policy.metric,
windowKind: input.policy.windowKind,
thresholdType: input.thresholdType,
budgetAmount: input.policy.amount,
observedAmount: input.amountObserved,
warnPercent: input.policy.warnPercent,
windowStart: input.windowStart.toISOString(),
windowEnd: input.windowEnd.toISOString(),
policyId: input.policy.id,
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
};
}
async function markApprovalStatus(
db: Db,
approvalId: string | null,
status: "approved" | "rejected",
decisionNote: string | null | undefined,
decidedByUserId: string,
) {
if (!approvalId) return;
await db
.update(approvals)
.set({
status,
decisionNote: decisionNote ?? null,
decidedByUserId,
decidedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(approvals.id, approvalId));
}
export function budgetService(db: Db, hooks: BudgetServiceHooks = {}) {
async function pauseScopeForBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "paused",
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"])));
return;
}
if (policy.scopeType === "project") {
await db
.update(projects)
.set({
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(eq(projects.id, policy.scopeId));
return;
}
await db
.update(companies)
.set({
status: "paused",
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(eq(companies.id, policy.scopeId));
}
async function pauseAndCancelScopeForBudget(policy: PolicyRow) {
await pauseScopeForBudget(policy);
await hooks.cancelWorkForScope?.({
companyId: policy.companyId,
scopeType: policy.scopeType as BudgetScopeType,
scopeId: policy.scopeId,
});
}
async function resumeScopeFromBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
return;
}
if (policy.scopeType === "project") {
await db
.update(projects)
.set({
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(projects.id, policy.scopeId), eq(projects.pauseReason, "budget")));
return;
}
await db
.update(companies)
.set({
status: "active",
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(companies.id, policy.scopeId), eq(companies.pauseReason, "budget")));
}
async function getPolicyRow(policyId: string) {
const policy = await db
.select()
.from(budgetPolicies)
.where(eq(budgetPolicies.id, policyId))
.then((rows) => rows[0] ?? null);
if (!policy) throw notFound("Budget policy not found");
return policy;
}
async function listPolicyRows(companyId: string) {
return db
.select()
.from(budgetPolicies)
.where(eq(budgetPolicies.companyId, companyId))
.orderBy(desc(budgetPolicies.updatedAt));
}
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const observedAmount = await computeObservedAmount(db, policy);
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const amount = policy.isActive ? policy.amount : 0;
const utilizationPercent =
amount > 0 ? Number(((observedAmount / amount) * 100).toFixed(2)) : 0;
return {
policyId: policy.id,
companyId: policy.companyId,
scopeType: policy.scopeType as BudgetScopeType,
scopeId: policy.scopeId,
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
metric: policy.metric as BudgetMetric,
windowKind: policy.windowKind as BudgetWindowKind,
amount,
observedAmount,
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
utilizationPercent,
warnPercent: policy.warnPercent,
hardStopEnabled: policy.hardStopEnabled,
notifyEnabled: policy.notifyEnabled,
isActive: policy.isActive,
status: policy.isActive
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
: "ok",
paused: scope.paused,
pauseReason: scope.pauseReason,
windowStart: start,
windowEnd: end,
};
}
async function createIncidentIfNeeded(
policy: PolicyRow,
thresholdType: BudgetThresholdType,
amountObserved: number,
) {
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const existing = await db
.select()
.from(budgetIncidents)
.where(
and(
eq(budgetIncidents.policyId, policy.id),
eq(budgetIncidents.windowStart, start),
eq(budgetIncidents.thresholdType, thresholdType),
ne(budgetIncidents.status, "dismissed"),
),
)
.then((rows) => rows[0] ?? null);
if (existing) return existing;
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const payload = buildApprovalPayload({
policy,
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
thresholdType,
amountObserved,
windowStart: start,
windowEnd: end,
});
const approval = thresholdType === "hard"
? await db
.insert(approvals)
.values({
companyId: policy.companyId,
type: "budget_override_required",
requestedByUserId: null,
requestedByAgentId: null,
status: "pending",
payload,
})
.returning()
.then((rows) => rows[0] ?? null)
: null;
return db
.insert(budgetIncidents)
.values({
companyId: policy.companyId,
policyId: policy.id,
scopeType: policy.scopeType,
scopeId: policy.scopeId,
metric: policy.metric,
windowKind: policy.windowKind,
windowStart: start,
windowEnd: end,
thresholdType,
amountLimit: policy.amount,
amountObserved,
status: "open",
approvalId: approval?.id ?? null,
})
.returning()
.then((rows) => rows[0] ?? null);
}
async function resolveOpenSoftIncidents(policyId: string) {
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(budgetIncidents.policyId, policyId),
eq(budgetIncidents.thresholdType, "soft"),
eq(budgetIncidents.status, "open"),
),
);
}
async function resolveOpenIncidentsForPolicy(
policyId: string,
approvalStatus: "approved" | "rejected" | null,
decidedByUserId: string | null,
) {
const openRows = await db
.select()
.from(budgetIncidents)
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
if (!approvalStatus || !decidedByUserId) return;
for (const row of openRows) {
await markApprovalStatus(db, row.approvalId ?? null, approvalStatus, "Resolved via budget update", decidedByUserId);
}
}
async function hydrateIncidentRows(rows: IncidentRow[]): Promise<BudgetIncident[]> {
const approvalIds = rows.map((row) => row.approvalId).filter((value): value is string => Boolean(value));
const approvalRows = approvalIds.length > 0
? await db
.select({ id: approvals.id, status: approvals.status })
.from(approvals)
.where(inArray(approvals.id, approvalIds))
: [];
const approvalStatusById = new Map(approvalRows.map((row) => [row.id, row.status]));
return Promise.all(
rows.map(async (row) => {
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
return {
id: row.id,
companyId: row.companyId,
policyId: row.policyId,
scopeType: row.scopeType as BudgetScopeType,
scopeId: row.scopeId,
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
thresholdType: row.thresholdType as BudgetThresholdType,
amountLimit: row.amountLimit,
amountObserved: row.amountObserved,
status: row.status as BudgetIncident["status"],
approvalId: row.approvalId ?? null,
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
resolvedAt: row.resolvedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}),
);
}
return {
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
const rows = await listPolicyRows(companyId);
return rows.map((row) => ({
...row,
scopeType: row.scopeType as BudgetScopeType,
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
}));
},
upsertPolicy: async (
companyId: string,
input: BudgetPolicyUpsertInput,
actorUserId: string | null,
): Promise<BudgetPolicySummary> => {
const scope = await resolveScopeRecord(db, input.scopeType, input.scopeId);
if (scope.companyId !== companyId) {
throw unprocessable("Budget scope does not belong to company");
}
const metric = input.metric ?? "billed_cents";
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
const amount = Math.max(0, Math.floor(input.amount));
const nextIsActive = amount > 0 && (input.isActive ?? true);
const existing = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, input.scopeType),
eq(budgetPolicies.scopeId, input.scopeId),
eq(budgetPolicies.metric, metric),
eq(budgetPolicies.windowKind, windowKind),
),
)
.then((rows) => rows[0] ?? null);
const now = new Date();
const row = existing
? await db
.update(budgetPolicies)
.set({
amount,
warnPercent: input.warnPercent ?? existing.warnPercent,
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
isActive: nextIsActive,
updatedByUserId: actorUserId,
updatedAt: now,
})
.where(eq(budgetPolicies.id, existing.id))
.returning()
.then((rows) => rows[0])
: await db
.insert(budgetPolicies)
.values({
companyId,
scopeType: input.scopeType,
scopeId: input.scopeId,
metric,
windowKind,
amount,
warnPercent: input.warnPercent ?? 80,
hardStopEnabled: input.hardStopEnabled ?? true,
notifyEnabled: input.notifyEnabled ?? true,
isActive: nextIsActive,
createdByUserId: actorUserId,
updatedByUserId: actorUserId,
})
.returning()
.then((rows) => rows[0]);
if (input.scopeType === "company" && windowKind === "calendar_month_utc") {
await db
.update(companies)
.set({
budgetMonthlyCents: amount,
updatedAt: now,
})
.where(eq(companies.id, input.scopeId));
}
if (input.scopeType === "agent" && windowKind === "calendar_month_utc") {
await db
.update(agents)
.set({
budgetMonthlyCents: amount,
updatedAt: now,
})
.where(eq(agents.id, input.scopeId));
}
if (amount > 0) {
const observedAmount = await computeObservedAmount(db, row);
if (observedAmount < amount) {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
} else {
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
if (row.notifyEnabled && observedAmount >= softThreshold) {
await createIncidentIfNeeded(row, "soft", observedAmount);
}
if (row.hardStopEnabled && observedAmount >= row.amount) {
await resolveOpenSoftIncidents(row.id);
await createIncidentIfNeeded(row, "hard", observedAmount);
await pauseAndCancelScopeForBudget(row);
}
}
} else {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: actorUserId ?? "board",
action: "budget.policy_upserted",
entityType: "budget_policy",
entityId: row.id,
details: {
scopeType: row.scopeType,
scopeId: row.scopeId,
amount: row.amount,
windowKind: row.windowKind,
},
});
return buildPolicySummary(row);
},
overview: async (companyId: string): Promise<BudgetOverview> => {
const rows = await listPolicyRows(companyId);
const policies = await Promise.all(rows.map((row) => buildPolicySummary(row)));
const activeIncidentRows = await db
.select()
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
.orderBy(desc(budgetIncidents.createdAt));
const activeIncidents = await hydrateIncidentRows(activeIncidentRows);
return {
companyId,
policies,
activeIncidents,
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
};
},
evaluateCostEvent: async (event: typeof costEvents.$inferSelect) => {
const candidatePolicies = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, event.companyId),
eq(budgetPolicies.isActive, true),
inArray(budgetPolicies.scopeType, ["company", "agent", "project"]),
),
);
const relevantPolicies = candidatePolicies.filter((policy) => {
if (policy.scopeType === "company") return policy.scopeId === event.companyId;
if (policy.scopeType === "agent") return policy.scopeId === event.agentId;
if (policy.scopeType === "project") return Boolean(event.projectId) && policy.scopeId === event.projectId;
return false;
});
for (const policy of relevantPolicies) {
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
const observedAmount = await computeObservedAmount(db, policy);
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
if (policy.notifyEnabled && observedAmount >= softThreshold) {
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
if (softIncident) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.soft_threshold_crossed",
entityType: "budget_incident",
entityId: softIncident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amountObserved: observedAmount,
amountLimit: policy.amount,
},
});
}
}
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
await resolveOpenSoftIncidents(policy.id);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
await pauseAndCancelScopeForBudget(policy);
if (hardIncident) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.hard_threshold_crossed",
entityType: "budget_incident",
entityId: hardIncident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amountObserved: observedAmount,
amountLimit: policy.amount,
approvalId: hardIncident.approvalId ?? null,
},
});
}
}
}
},
getInvocationBlock: async (
companyId: string,
agentId: string,
context?: { issueId?: string | null; projectId?: string | null },
) => {
const agent = await db
.select({
status: agents.status,
pauseReason: agents.pauseReason,
companyId: agents.companyId,
name: agents.name,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
const company = await db
.select({
status: companies.status,
pauseReason: companies.pauseReason,
name: companies.name,
})
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
if (company.status === "paused") {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason:
company.pauseReason === "budget"
? "Company is paused because its budget hard-stop was reached."
: "Company is paused and cannot start new work.",
};
}
const companyPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "company"),
eq(budgetPolicies.scopeId, companyId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
const observed = await computeObservedAmount(db, companyPolicy);
if (observed >= companyPolicy.amount) {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
};
}
}
if (agent.status === "paused" && agent.pauseReason === "budget") {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent is paused because its budget hard-stop was reached.",
};
}
const agentPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "agent"),
eq(budgetPolicies.scopeId, agentId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
const observed = await computeObservedAmount(db, agentPolicy);
if (observed >= agentPolicy.amount) {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
};
}
}
const candidateProjectId = context?.projectId ?? null;
if (!candidateProjectId) return null;
const project = await db
.select({
id: projects.id,
name: projects.name,
companyId: projects.companyId,
pauseReason: projects.pauseReason,
pausedAt: projects.pausedAt,
})
.from(projects)
.where(eq(projects.id, candidateProjectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== companyId) return null;
const projectPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "project"),
eq(budgetPolicies.scopeId, project.id),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
const observed = await computeObservedAmount(db, projectPolicy);
if (observed >= projectPolicy.amount) {
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
};
}
}
if (!project.pausedAt || project.pauseReason !== "budget") return null;
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project is paused because its budget hard-stop was reached.",
};
},
resolveIncident: async (
companyId: string,
incidentId: string,
input: BudgetIncidentResolutionInput,
actorUserId: string,
): Promise<BudgetIncident> => {
const incident = await db
.select()
.from(budgetIncidents)
.where(eq(budgetIncidents.id, incidentId))
.then((rows) => rows[0] ?? null);
if (!incident) throw notFound("Budget incident not found");
if (incident.companyId !== companyId) throw notFound("Budget incident not found");
const policy = await getPolicyRow(incident.policyId);
if (input.action === "raise_budget_and_resume") {
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
const currentObserved = await computeObservedAmount(db, policy);
if (nextAmount <= currentObserved) {
throw unprocessable("New budget must exceed current observed spend");
}
const now = new Date();
await db
.update(budgetPolicies)
.set({
amount: nextAmount,
isActive: true,
updatedByUserId: actorUserId,
updatedAt: now,
})
.where(eq(budgetPolicies.id, policy.id));
if (policy.scopeType === "company" && policy.windowKind === "calendar_month_utc") {
await db
.update(companies)
.set({ budgetMonthlyCents: nextAmount, updatedAt: now })
.where(eq(companies.id, policy.scopeId));
}
if (policy.scopeType === "agent" && policy.windowKind === "calendar_month_utc") {
await db
.update(agents)
.set({ budgetMonthlyCents: nextAmount, updatedAt: now })
.where(eq(agents.id, policy.scopeId));
}
await resumeScopeFromBudget(policy);
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: now,
updatedAt: now,
})
.where(and(eq(budgetIncidents.policyId, policy.id), eq(budgetIncidents.status, "open")));
await markApprovalStatus(db, incident.approvalId ?? null, "approved", input.decisionNote, actorUserId);
} else {
await db
.update(budgetIncidents)
.set({
status: "dismissed",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(budgetIncidents.id, incident.id));
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
}
await logActivity(db, {
companyId: incident.companyId,
actorType: "user",
actorId: actorUserId,
action: "budget.incident_resolved",
entityType: "budget_incident",
entityId: incident.id,
details: {
action: input.action,
amount: input.amount ?? null,
scopeType: incident.scopeType,
scopeId: incident.scopeId,
},
});
const [updated] = await hydrateIncidentRows([{
...incident,
status: input.action === "raise_budget_and_resume" ? "resolved" : "dismissed",
resolvedAt: new Date(),
updatedAt: new Date(),
}]);
return updated!;
},
};
}