Skip to content

Budgets & variance reporting

Backend domain: app/graphql/budgets/ Migration: add_budgets RBAC Path: BUDGETS

Overview

A Budget is a named plan against a FiscalYear (optionally scoped to a project or office), containing one BudgetLine per (account, period_number) cell — i.e. up to 12 cells per account per year. Statuses: DRAFT → APPROVED → ARCHIVED.

Variance reporting (budgetVariance) joins the budget lines to the period calendar and to posted ledger lines, returning budget, actual, variance, and variancePct per account for any date range.

Setup prerequisites

  1. A FiscalYear must exist (see period-close.md) — the budget references one.
  2. The chart of accounts the budget references must exist.

GraphQL surface

Queries

budgets: [Budget!]!
budget(budgetId: UUID!): Budget!
budgetVariance(
  budgetId: UUID!,
  startDate: Date!,
  endDate: Date!
): [BudgetVarianceRow!]!

Budget fields: id, name, fiscalYearId, status (DRAFT | APPROVED | ARCHIVED), approvedAt, approvedById, projectId, officeId, plus nested lines: [BudgetLine!]!.

BudgetLine: id, budgetId, accountId, periodNumber (1-12), amount.

BudgetVarianceRow: accountId, budget, actual, variance (= actual − budget), variancePct.

Mutations

createBudget(budget: BudgetInput!): Budget!
approveBudget(budgetId: UUID!): Budget!
archiveBudget(budgetId: UUID!): Budget!

BudgetInput: name, fiscalYearId, lines: [BudgetLineInput!]!, optional projectId, officeId.

BudgetLineInput: accountId, periodNumber, amount.

Behaviour to handle in the UI

  • The natural editor is a grid: rows = revenue/expense accounts, columns = 12 periods, cells = amount. The bulk createBudget mutation accepts the full grid in one call.
  • Don't constrain to revenue/expense in the UI — the backend accepts any account, but planning revenue/expense is the typical use.
  • After creation, an "Approve" CTA locks edits (the backend doesn't block edits today — see follow-up — but the UI should respect the status flag).
  • For variance reports, render as a P&L-style table with budget | actual | variance | variance% columns. Color-code variance (favorable/unfavorable depends on whether the account family is revenue or expense — frontend has to interpret).

Error states

None notable. Unique-constraint violation on (budget_id, account_id, period_number) is the only structural rejection; that's prevented by sending one line per cell.

Suggested UX flow

  1. Budgets list — group by fiscal year, show status badge, project/office tags.
  2. Budget grid editor — paste-from-Excel-friendly grid; columns = period number + label (Jan…Dec).
  3. Budget variance report — date-range picker → P&L-style variance table. Drill from a row into the underlying ledger entries for that account in that range.

Open follow-ups (not yet implemented)

  • Backend doesn't actually lock edits on APPROVED budgets; rely on UI for now or request a server-side check.
  • createFromPriorYear(fiscalYearId, growthPct) helper was specced but not built — would let users seed from posted GL × growth. Frontend can simulate by querying the prior year's P&L and applying the multiplier client-side.
  • No CSV import endpoint; provide a paste-from-spreadsheet UX instead.
  • variancePct is computed as variance / budget * 100 and is 0 when budget is 0 — surface as "—" rather than "0%" when budget is zero.
  • The variance query uses absolute net activity (debits - credits) per account; for revenue accounts the sign will be inverted relative to common P&L expectations. UI should apply the sign flip based on account type (use the accounts query to fetch accountType).