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.
Decisions
Section titled “Decisions”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).
B4. One optional axis per budget
Section titled “B4. One optional axis per budget”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.
B6. Base currency
Section titled “B6. Base currency”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”).
B9. Entry by grid, set by row
Section titled “B9. Entry by grid, set by row”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; incomeactual − 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). provisionalas every report: a period in range is still open.
B12. Export
Section titled “B12. Export”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>.
B13. Never deleted, always audited
Section titled “B13. Never deleted, always audited”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.
B14. Names
Section titled “B14. Names”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.
Permissions
Section titled “Permissions”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.
API (api/openapi.yaml first)
Section titled “API (api/openapi.yaml first)”| 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.
Tests that must exist
Section titled “Tests that must exist”- 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-Matchis 409revision_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.Actualsagainst 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.PercentOfrounding.- Export: params validation, a CSV golden, the
report_exportCHECK. - Web: grid dirty tracking and save payload; CSV download shape; report tab.
- e2e: create budget → enter grid → approve → post an actual → report shows it.
For the owner to confirm
Section titled “For the owner to confirm”- 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
manageby default, so the default roles already separate the two. The alternative, a DB ruleapproved_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’smanagegrant does not includeapprove. 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). - 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.
- “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.Actualsis unchanged. - 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.