Skip to content

RFC-006: Finance — Budget Management Implementation

  • Status: Draft
  • Author(s): Aditya Lohumi
  • Created: 2026-02-26
  • Updated: 2026-02-26
  • TSC Decision: Pending
  • Supersedes: —
  • Superseded by: —

This RFC covers the implementation of Phase 04 (Budget Management) of the finance library. It defines the controller logic for Budget and BudgetRevision, the budget utilisation tracking mechanism triggered by GL postings, and the configurable budget enforcement behaviour (warn vs. block) as decided in RFC-003 §10.

Enterprises and public-sector organisations alike operate under defined budget allocations tied to account heads and cost centres. The system must track utilised spend against approved budget allocations, raise alerts when approaching or exceeding limits, and maintain a full revision audit trail. The enforcement mode must be configurable to allow initial deployment in advisory/warning mode before switching to hard-block mode.


On submit of a Budget:

  • Lock the allocated_amount (cannot be changed directly; only via BudgetRevision)
  • Status transitions to "Active"
class BudgetController(Controller[Budget]):
async def before_submit(self, doc: Budget) -> None:
"""Validate budget before activation."""
if doc.allocated_amount <= Decimal("0"):
raise ValidationError("Allocated amount must be greater than zero.")
async def after_submit(self, doc: Budget) -> None:
doc.status = "Active"

2. BudgetRevision — Audit Trail & Budget Update

Section titled “2. BudgetRevision — Audit Trail & Budget Update”

On submit of a BudgetRevision:

  1. Validate previous_amount == Budget.allocated_amount (optimistic concurrency check)
  2. Update Budget.allocated_amount = revision.revised_amount
  3. Transition BudgetRevision.status = "Approved"
class BudgetRevisionController(Controller[BudgetRevision]):
async def before_submit(self, doc: BudgetRevision) -> None:
budget = await self.db.get(Budget, doc.budget)
if budget.status != "Active":
raise ValidationError("Can only revise an Active budget.")
if budget.allocated_amount != doc.previous_amount:
raise ValidationError(
"Budget has been concurrently modified. "
"Refresh and re-enter the revision."
)
async def after_submit(self, doc: BudgetRevision) -> None:
await self.db.set_value(
Budget, doc.budget, "allocated_amount", doc.revised_amount
)
doc.status = "Approved"

Budget.utilized_amount is updated whenever a GL entry is posted against the linked account and cost_center within the fiscal_year. This is driven by a hook from the GL posting layer (called from PaymentVoucher, JournalEntry, etc. controllers after Book-Keeper confirms the GL entry):

async def update_budget_utilization(
company: str,
account: str,
cost_center: str | None,
fiscal_year: str,
amount: Decimal,
reverse: bool = False,
) -> None:
"""
Increment (or decrement on cancel/reverse) budget utilization.
Called by PaymentVoucher, JournalEntry controllers after GL posting.
"""
budget = await db.get_value(
Budget,
filters={
"company": company,
"account": account,
"cost_center": cost_center,
"fiscal_year": fiscal_year,
"status": "Active",
},
)
if not budget:
return # No budget configured for this account head — no-op
delta = -amount if reverse else amount
new_utilized = budget.utilized_amount + delta
settings = await FinanceSettings.get_single()
if not reverse and new_utilized > budget.allocated_amount:
if settings.budget_enforcement_mode == "block":
raise BudgetExceededError(
f"Budget exceeded for account '{account}'. "
f"Allocated: {budget.allocated_amount}, "
f"Would be utilized: {new_utilized}."
)
else:
# "warn" mode — show warning but allow posting
add_message(
type="warning",
message=f"Budget limit exceeded for account '{account}'. "
f"Allocated: {budget.allocated_amount}, "
f"Utilized: {new_utilized}.",
)
await db.set_value(Budget, budget.name, "utilized_amount", new_utilized)

4. Budget Enforcement Mode (from FinanceSettings)

Section titled “4. Budget Enforcement Mode (from FinanceSettings)”
Mode Behaviour
"warn" (default) Allow GL posting; show warning on the document form
"block" Raise BudgetExceededError; GL posting is blocked until budget is revised

This setting lives in FinanceSettings.budget_enforcement_mode.

When a FiscalYear is closed, all Budget records for that fiscal year should transition to "Closed". This is triggered from the FiscalYearController.after_save (when is_closed transitions to True):

async def after_save(self, doc: FiscalYear) -> None:
if doc.is_closed:
await self.db.update(
Budget,
filters={"fiscal_year": doc.name, "status": "Active"},
values={"status": "Closed"},
)
Endpoint Description
Standard CRUD Budget, BudgetRevision
GET /api/resource/Budget/{id}/utilisation_report Returns allocation vs utilisation breakdown

# Activate a budget
budget = await Budget.new({
"budget_number": "BDG-2026-GS-001",
"company": "Acme Corp",
"fiscal_year": "FY 2026-27",
"account": "General Stores Expenses",
"cost_center": "Ops-Delhi",
"allocated_amount": Decimal("500000.00"),
})
await budget.submit() # status → "Active"
# Revise it
revision = await BudgetRevision.new({
"revision_number": "REV-2026-001",
"budget": budget.name,
"revised_date": date(2026, 6, 1),
"previous_amount": Decimal("500000.00"),
"revised_amount": Decimal("600000.00"),
"reason": "Additional demand due to increased project scope",
})
await revision.submit()
# Budget.allocated_amount is now 600,000

  • Budget utilisation is updated synchronously inside the GL posting transaction. If budget tracking is slow (e.g., complex multi-account queries), it adds latency to every payment submission.
  • Concurrent GL postings against the same budget may cause race conditions on utilized_amount. A database-level SELECT FOR UPDATE or optimistic locking strategy must be applied.
Alternative Pros Cons Why Not
Compute utilisation on-the-fly from GL Always accurate, no stored value Expensive read for every budget check Performance at scale
Async utilisation update (queue) Non-blocking GL posting Utilisation lags behind reality Unsafe for block enforcement mode

No migration required — Budget and BudgetRevision are new doctypes.

  • Should budget checking apply to all GL postings (including system-generated JEs like depreciation) or only user-initiated payments?
  • How should budget enforcement interact with cancelled documents — i.e., when a PaymentVoucher is cancelled, utilized_amount must be decremented. Is this handled by the reversal reverse=True flag in update_budget_utilization?
  • Implement BudgetController.before_submit — amount validation
  • Implement BudgetController.after_submit — status transition
  • Implement BudgetRevisionController.before_submit — concurrent modification guard
  • Implement BudgetRevisionController.after_submit — update Budget.allocated_amount
  • Implement update_budget_utilization utility function
  • Integrate update_budget_utilization into PaymentVoucher and JournalEntry controllers
  • Implement budget closure on FiscalYear close
  • Implement utilisation_report endpoint
  • Unit tests for all controller methods
  • Concurrency tests for budget utilisation updates
  • Integration tests covering warn mode and block mode
Test Scenario
test_budget_submit_negative_amount allocated_amount ≤ 0ValidationError
test_budget_revision_stale_amount previous_amount mismatch → ValidationError
test_budget_revision_closed_budget Revising a non-Active budget → ValidationError
test_utilisation_warn_mode Over-limit in warn mode → warning message, posting proceeds
test_utilisation_block_mode Over-limit in block mode → BudgetExceededError
test_utilisation_reverse Cancel decrements utilized_amount
test_budget_closure_on_fiscal_year_close Closing FY transitions all Active budgets to Closed
  • Simulate concurrent GL postings against the same budget; verify utilized_amount is correctly accumulated.
  • RFC-003: Finance Library Architecture (Decision 2 — warn/block enforcement)
  • RFC-004: Foundation & F&A Masters