diff --git a/doc/COST-ACCOUNTING.md b/doc/COST-ACCOUNTING.md index f3c10b14bb6..4363f279cdf 100644 --- a/doc/COST-ACCOUNTING.md +++ b/doc/COST-ACCOUNTING.md @@ -166,7 +166,9 @@ provider adapters with missing prices still do. Budget policy updates may omit unchanged settings, including the amount for an existing policy. Omitted settings are read under the accounting transaction lock. -Creating a policy still requires an amount. Generic agent and company budget +Creating a policy still requires an amount. Partial edits preserve an explicitly +disabled policy; the legacy monthly-cap endpoints explicitly enable a positive +cap and disable a zero cap. Generic agent and company budget edits deliver hard-stop cancellation after their transaction commits. Status-card update costs retain the ledger's fractional-cent storage precision. diff --git a/scripts/test-accounting-mutations.mjs b/scripts/test-accounting-mutations.mjs index 5cbb19022b2..79f27f6b0be 100644 --- a/scripts/test-accounting-mutations.mjs +++ b/scripts/test-accounting-mutations.mjs @@ -9,6 +9,7 @@ const artifactDirectory = path.join(root, "coverage/accounting/mutations"); const targets = { deduplication: "deduplicates a retried receipt", company: "isolates company reporting", projection: "conserves agent projections", threshold: "stops at the exact budget boundary" }; const evidence = []; const persist = () => writeFile(path.join(root, "coverage/accounting/mutations.json"), JSON.stringify({ generatedAt: new Date().toISOString(), evidence }, null, 2)); +await rm(artifactDirectory, { recursive: true, force: true }); await mkdir(artifactDirectory, { recursive: true }); try { for (const name of ["baseline", ...Object.keys(targets)]) { diff --git a/server/src/__tests__/cost-accounting-reliability.test.ts b/server/src/__tests__/cost-accounting-reliability.test.ts index 642c25f8b12..8e285800d0f 100644 --- a/server/src/__tests__/cost-accounting-reliability.test.ts +++ b/server/src/__tests__/cost-accounting-reliability.test.ts @@ -311,6 +311,10 @@ databaseDescribe("cost accounting reliability (PostgreSQL)", () => { await budgets.upsertPolicy(f.company.id, { ...identity, amount: 200, hardStopEnabled: false, warnPercent: 60, notifyEnabled: false }, "other-operator"); const saved = await budgets.upsertPolicy(f.company.id, upsertBudgetPolicySchema.parse({ ...identity, reservationCents: "10" }), "board"); expect(saved).toMatchObject({ amount: 200, hardStopEnabled: false, warnPercent: 60, notifyEnabled: false, reservationCents: "10.0000000" }); + await budgets.upsertPolicy(f.company.id, { ...identity, isActive: false }, "other-operator"); + const disabled = await budgets.upsertPolicy(f.company.id, upsertBudgetPolicySchema.parse({ ...identity, amount: 300 }), "board"); + expect(await db.select({ amount: budgetPolicies.amount, isActive: budgetPolicies.isActive }).from(budgetPolicies).where(eq(budgetPolicies.id, disabled.policyId))).toEqual([{ amount: 300, isActive: false }]); + expect(disabled).toMatchObject({ amount: 0, isActive: false, hardStopEnabled: false, warnPercent: 60, notifyEnabled: false, reservationCents: "10.0000000" }); await expect(budgets.upsertPolicy(f.company.id, { scopeType: "project", scopeId: f.project.id, reservationCents: "1" }, "board")).rejects.toThrow("Amount is required"); }); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 4c4057aa3ac..862ac09c666 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -398,6 +398,7 @@ describe("cost routes", () => { scopeType: "agent", scopeId: "agent-1", amount: 2500, + isActive: true, windowKind: "calendar_month_utc", }, "board-user", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 678d1c6c921..8de78282c20 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -279,8 +279,9 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan const router = Router(); const importTransferSpoolRoot = options?.importTransferSpoolRoot ?? resolveDefaultImportTransferSpoolRoot(); - const heartbeat = heartbeatService(db); - const svc = companyService(db, { cancelWorkForScope: heartbeat.cancelBudgetScopeWork }); + const svc = companyService(db, { + cancelWorkForScope: (scope) => heartbeatService(db).cancelBudgetScopeWork(scope), + }); const agents = agentService(db); const portability = companyPortabilityService(db, storage); const access = accessService(db); diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index d30a4de3877..b887f08be30 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -325,6 +325,7 @@ export function costRoutes( scopeType: "company", scopeId: companyId, amount: req.body.budgetMonthlyCents, + isActive: req.body.budgetMonthlyCents > 0, windowKind: "calendar_month_utc", }, req.actor.userId ?? "board", @@ -346,6 +347,7 @@ export function costRoutes( 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, diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 6829fb6e4ac..aed0355d3ba 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -831,7 +831,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (normalizedPatch.budgetMonthlyCents !== undefined) { await budgetServiceInTransaction(txDb, publications).upsertPolicy(existing.companyId, { - scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents, windowKind: "calendar_month_utc", + scopeType: "agent", scopeId: id, amount: normalizedPatch.budgetMonthlyCents, isActive: normalizedPatch.budgetMonthlyCents > 0, windowKind: "calendar_month_utc", }, options?.recordRevision?.createdByUserId ?? null); } const normalizedUpdated = await agentService(txDb).getById(updated.id); diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 91ccc2e1880..83e304364aa 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -627,7 +627,7 @@ export function budgetServiceInTransaction(db: Db, publications: ActivityPublica if (!existing && input.amount === undefined) throw unprocessable("Amount is required for a new budget policy"); const amount = Math.max(0, Math.floor(input.amount ?? existing!.amount)); - const nextIsActive = amount > 0 && (input.isActive ?? (input.amount !== undefined ? true : existing?.isActive ?? true)); + const nextIsActive = amount > 0 && (input.isActive ?? existing?.isActive ?? true); const now = new Date(); const row = existing ? await db diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 1abc4321b82..c83e214a9ba 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -439,7 +439,7 @@ export function companyService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (data.budgetMonthlyCents !== undefined) { await budgetServiceInTransaction(tx as unknown as Db, budgetPublications).upsertPolicy(id, { - scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, windowKind: "calendar_month_utc", + scopeType: "company", scopeId: id, amount: data.budgetMonthlyCents, isActive: data.budgetMonthlyCents > 0, windowKind: "calendar_month_utc", }, actor.actorType === "user" ? actor.actorId : null); const [budgetUpdated] = await tx.select().from(companies).where(eq(companies.id, id)); Object.assign(updated, budgetUpdated);