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