Skip to content

Budgets and budget-vs-actual (Phase 2): design

Status: decided under the owner’s “complete phase 2” instruction, 2026-09-27 (decisions B1–B14 below; compliance-sensitive ones repeated under “For the owner to confirm”). Roadmap: Phase 2, “Budgets per account / period / axis, budget-vs-actual”. Architecture: §19 Phase 2 table (“Budget per account per period per analytic axis, budget-vs-actual reports”, AutoCount GL), §10.1 chart, §10.2 analytic axes and ratio distributions, §10.4 fiscal periods, §10.7 one query layer in platform/reporting. Branch: phase-2-budgets. Migrations: 00370–00379 (this item uses 00370–00372).

In: budgets per fiscal year, optionally split by one analytic axis; an amount per (account × fiscal period × optional analytic account); versions (the original, then revisions), each a draft until approved and then frozen; entry by grid in the web and by CSV import; a budget-vs-actual report (budget, actual, variance, % used, per row and in totals) as JSON and as a CSV/XLSX/PDF export.

Out, and why:

  • Checking spend against budget at claim, invoice or payment time. It needs a commitment model (what is approved but not yet posted), a policy per organization (warn or block), and a place in the workflow (a gate or a signal to approvers). Each of those is a decision of its own; a v1 that warns wrongly is worse than none. Its own roadmap item when asked.
  • Balance-sheet budgets (cash-flow and capex planning). v1 budgets income and expense accounts only (B5).
  • Budgets in a foreign currency. Budgets are in base currency, the currency every actual is already summed in (B6).
  • Budgeting by more than one axis at once (cost centre × project). One axis per budget (B4).
  • A multi-step approval workflow for budgets (B8).
  • Unbudgeted spend per analytic account on an axis budget (B11), and “original vs revised” side by side in one report (B12). Run the report against each version instead.
  • Copying last year’s actuals into a new budget, spreading an annual amount across periods, and forecasts. Grid and CSV cover entry; add these when asked.

B1. Budgets are not ledger entries, and live beside the chart

Section titled “B1. Budgets are not ledger entries, and live beside the chart”

A budget never posts, never touches journal_*, and never registers a posting rule. Its tables are reference data like analytic_axis, so the code is a package beside the chart: internal/platform/accounting/budget, with its routes on the existing accounting module (internal/modules/accounting, /accounting/budgets…, /accounting/budget-versions…). No new module, so no new wiring in internal/app or the cmd binaries. The report is served by internal/modules/reports and exported by internal/platform/reporting/export, like every other report.

B2. Actuals come from the one query layer, unchanged in meaning

Section titled “B2. Actuals come from the one query layer, unchanged in meaning”

platform/reporting stays “ledger figures only” (§10.7, its doc.go). It gains one exported function, reporting.Actuals, which returns what the P&L already computes: base-currency movements per account over a date range, closing entries excluded (Plan 9 Ruling R5), income credit-positive and expense debit-positive (R10), and, for requested analytic accounts, their stored shares (analytic_distribution.base_amount, R3/R8) with descendants included, exactly as Scope.AnalyticAccountIDs already means. The budget package joins those figures to budget amounts; reporting never reads a budget table.

A test pins the meaning: for a budget with no axis, the report’s total actual expense and income over a range equal reporting.ProfitAndLoss over the same range.

B3. Granularity: account × fiscal period × optional analytic account

Section titled “B3. Granularity: account × fiscal period × optional analytic account”

A budget belongs to one fiscal year (fiscal_year), and its amounts are per fiscal_period of that year (the 12 monthly periods fiscal.CreateYear tiles). A database foreign key through fiscal_period (org_id, fiscal_year_id, id) makes a cell in another year’s period impossible. To budget next year, its fiscal year must exist first (POST /accounting/fiscal-years).

A budget either has no axis (one row per account) or names one analytic_axis (every row is an account and an analytic account on that axis — never a bare account, so no row mixes “the whole account” with “one project’s share of it”). Two budgets for the same year can use different axes (a cost-centre budget and a project budget). Rules, enforced in Go with the database as backstop:

  • the analytic account is on the budget’s axis (three-column FK, as analytic_distribution);
  • the axis applies to the account’s type (analytic_axis.applies_to), otherwise every actual would be zero;
  • within one version and one account, no row’s analytic account is an ancestor of another’s (actual includes descendants, B2, so both rows would count the same spend twice).

B5. Income and expense accounts only; postable only

Section titled “B5. Income and expense accounts only; postable only”

A row names a postable account of type income or expense. Parent accounts are subtotals in the report, not budget targets (§10.1: only leaves are postable, and the same holds here). Amounts are ≥ 0 in the account’s natural direction (income credit, expense debit), which is how people write a budget.

Budget amounts are NUMERIC(19,4) in the organization’s base currency (company_profile), money.Amount in Go, strings on the wire. Actuals are already base (frozen base amounts).

B7. Versions: the original, then revisions; one draft at a time

Section titled “B7. Versions: the original, then revisions; one draft at a time”

budget_version rows are numbered from 1. Version 1 is shown as “Original”, version n ≥ 2 as “Revision n−1” (labels are translation keys in the web, not stored text). Status is a string: draft, approved or discarded.

  • Creating a budget creates version 1 as a draft.
  • A draft’s cells can be saved and imported; approving freezes it. A draft can be discarded.
  • New revision creates the next version as a draft, copying every cell of the latest approved version (or starting empty if none is approved). Refused while a draft exists (a partial unique index: one draft per budget).
  • The current version is the highest-numbered approved one. Superseded is derived, never stored.
  • An approved or discarded version is frozen by trigger: its row cannot change, and no cell can be inserted or updated under it. Nothing is deleted (B13).

B8. Approval is a lock with its own permission, not a workflow kind

Section titled “B8. Approval is a lock with its own permission, not a workflow kind”

Approve (draft → approved) requires accounting.budget.approve; saving, importing, discarding and revising require accounting.budget.manage; reading budgets requires accounting.ledger.read. Why not a workflow kind: a budget moves no money, so what the engine brings (posting in the same transaction, amount thresholds, the superior chain, project routing) does not apply, and a kind would need a seeded graph, grants, a validator rule for a kind with no posting state, and provisioning, for one approve button. If customers want a multi-step approval, a budget kind can be added later: an instance_id column on budget_version, whose approved state then sets the status this item sets directly.

The approver may be the member who prepared the draft (see “For the owner to confirm”).

The web grid shows one row per (account [× analytic account]) and one column per period, plus a row total. Saving sends the rows that changed; each row sent replaces its 12 cells (an empty cell is zero). Rows not sent are untouched. A row is removed by zeroing it; the report drops rows whose budget and actual are both zero. Every write carries If-Match with the version’s revision (the payables pattern), so two people editing the same draft cannot silently overwrite each other.

B10. CSV import: the grid’s own shape, all or nothing

Section titled “B10. CSV import: the grid’s own shape, all or nothing”

POST /accounting/budget-versions/{id}/import takes { "csv": "<text>" } (JSON, so the API keeps one content type; at most 1 MiB and 5,000 rows). Header: account_code,analytic_code,P01,…,P12 — analytic_code present and blank for a budget with no axis, required for one with an axis. Each row replaces that row’s 12 cells, the same semantics as B9. A UTF-8 byte-order mark is dropped; blank cells are zero; amounts are plain decimals with a . point, and , thousands separators are accepted (Excel writes them; Malaysia uses . as the decimal point). Any error refuses the whole file with a list of row N, column X: reason (up to 50). The web’s “Download CSV” writes the current grid in this exact shape, so a spreadsheet round trip is the template.

B11. Budget-vs-actual: rows, variance, % used

Section titled “B11. Budget-vs-actual: rows, variance, % used”

GET /reports/budget-vs-actual?budget_version_id=&from=&to= under reports.read. from must be the first day of a period of the budget’s fiscal year and to the last day of a period of the same year, on or after from (so a budget figure is always whole periods). Any version may be reported; a draft or discarded one is labelled as such in the response and the export.

  • Rows. A budget with no axis: every budgeted account, plus every postable income or expense account with a non-zero actual in range (unbudgeted spend is what a budget-vs-actual exists to show). A budget with an axis: every budgeted (account, analytic account) row; the actual is that analytic account’s stored shares, descendants included. Spend on other analytic accounts, or untagged, is not shown (out of scope; the report meta says so).
  • Budget = Σ the row’s cells for the periods in range. Actual per B2.
  • Variance is favourable-positive: expense budget − actual; income actual − budget.
  • % used = actual ÷ budget × 100, rounded half away from zero to one decimal, as a string; null when the budget is zero. Computed in kernel/money (money.PercentOf), never a float.
  • Totals: income and expense sections separately (adding them is meaningless), and a net line (income − expense) for budget and actual with its variance (actual − budget).
  • provisional as every report: a period in range is still open.

budget_vs_actual joins report_export’s report list (migration 00372) and the export pipeline (export.Params gains budget_version_id; from/to are reused). The export’s header block states the budget, the version and its status, the axis if any, and the B11 row rule. Filename budget_vs_actual_<from>_<to>.<format>.

budget is never updated after creation (REVOKE UPDATE, DELETE); budget_version and budget_line are amendable while draft and frozen after (REVOKE DELETE), all in retention_test.go’s lists. A budget is not a transaction record under ITA s.82, but an approved budget is the evidence of what management authorised, and keeping it costs nothing. Every write is one audit_log row: create, save (changed cells with before and after), import (file SHA-256, row count, changed cells), approve, discard, revise.

A budget’s name is text an organization types (like analytic_account.name and party.legal_name), unique per fiscal year case-insensitively, not a product-shipped name, so it is a column. Version and status labels are translation keys. en/ms/zh in the web.

accounting.budget.manage — create budgets, save and import drafts, discard, revise. accounting.budget.approve — approve a draft. Reading budgets: accounting.ledger.read. The report: reports.read; its export: reports.export.

Seeded: a new Owner gets both (it gets every registered key); a new Finance role gets accounting.budget.manage (provisioning.RegisterRolePermissions), so preparing and approving are different roles by default. Existing organizations: migration 00371 grants the same to system Owner and Finance roles and bumps perm_version.

Method, path Permission What
GET /accounting/budgets?fiscal_year_id= ledger.read List, with current and draft version ids
POST /accounting/budgets budget.manage {fiscal_year_id, name, axis_id?} → budget + draft v1
GET /accounting/budgets/{id} ledger.read Budget and its versions
POST /accounting/budgets/{id}/revisions budget.manage Next version as a draft
GET /accounting/budget-versions/{id} ledger.read Version, periods, rows (ETag)
PUT /accounting/budget-versions/{id}/rows budget.manage, If-Match Replace the rows sent
POST /accounting/budget-versions/{id}/import budget.manage, If-Match {csv}
POST /accounting/budget-versions/{id}/approve budget.approve, If-Match Freeze
POST /accounting/budget-versions/{id}/discard budget.manage, If-Match Discard
GET /reports/budget-vs-actual reports.read B11
POST /reports/exports (report: budget_vs_actual) reports.export B12

Sidebar Accounting gains Budgets (/accounting/budgets): a DataTable list (tableId budgets) with a New budget dialog; a budget page with the version picker, the grid, Save, Import CSV, Download CSV, Approve, Discard, New revision, and a link to the report. Reports gains a Budget vs actual tab: budget and version pickers, a period range, the table, and the existing export menu. en/ms/zh.

  • RLS and retention for the three tables (existing drift tests, once listed).
  • Trigger backstops, presence first: a draft accepts a cell, an approved version refuses one; a draft version row changes, an approved one refuses; a line naming an analytic account on another axis, or none on an axis budget, is refused. Each sabotaged once.
  • One draft per budget; revision copies the approved cells exactly.
  • Save: set-by-row semantics; a stale If-Match is 409 revision_mismatch (the payables mapping); income/expense/postable only; axis rules of B4 including the ancestor overlap.
  • CSV: header, BOM, blanks, thousands separators, negative, unknown codes, wrong column count, error list capped, all-or-nothing.
  • reporting.Actuals against the golden books; the P&L equality of B2.
  • Report: period alignment refusals; variance signs for income and expense; % used rounding and zero budget; unbudgeted accounts shown on a no-axis budget; descendant shares on an axis budget; totals and net.
  • money.PercentOf rounding.
  • Export: params validation, a CSV golden, the report_export CHECK.
  • Web: grid dirty tracking and save payload; CSV download shape; report tab.
  • e2e: create budget → enter grid → approve → post an actual → report shows it.
  1. The preparer may approve their own budget. Chosen because a budget moves no money and a single-Owner organization must be able to approve one. Finance holds only manage by default, so the default roles already separate the two. The alternative, a DB rule approved_by <> last editor, would block every organization with only one Owner. Ruled: O-8 — the preparer (the version’s creator, or anyone who saved or imported its cells) cannot approve it (budget_self_approval); Finance’s manage grant does not include approve. Changed (2026-09-29, D1): O-8’s refusal locked out a one-member organization entirely, against this item’s own intent — the audited sole-owner exception now also covers budget approval (ledger BUD-M4).
  2. Budgets are kept forever, like records (no DELETE grant), but are not treated as ITA s.82 records. As far as I know no Malaysian statute requires a company to keep its budgets. This is my reading, not a tax agent’s. Making drafts deletable would lose the trail of what was proposed and then discarded. Ruled: O-8 — no DELETE grant on budgets.
  3. “Actual” is by posting date, the same as the P&L. A purchase invoice therefore counts in the month it was approved, not the month of its invoice date (see AP’s open decision on posting date). If that decision changes, budget-vs-actual follows it automatically. Ruled: O-8, following O-12 (an approved purchase invoice posts on its invoice date, or the first open date after a closed period) — reporting.Actuals is unchanged.
  4. No spend check against budget at claim, invoice or payment time in v1. The alternative (warn or block at approval) needs a commitment model and a per-organization policy; it is its own item. Confirmed (2026-09-29): it needs a commitment model and is its own item.