Skip to content

Budgets and Budget-vs-Actual Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A finance user creates a budget for a fiscal year (optionally by one analytic axis), enters an amount per account × period (× analytic account) in a web grid or by CSV import, has it approved (which freezes that version), revises it as a new version later, and reads a budget-vs-actual report (budget, actual, favourable-positive variance, % used, section totals and net) whose actuals are exactly the P&L’s figures, as JSON and as a CSV/XLSX/PDF export.

Architecture: One new platform package, internal/platform/accounting/budget, beside chart, fiscal and analytic. It owns three tables (budget, budget_version, budget_line), the version lifecycle (draft → approved | discarded; revise = copy the latest approved into a new draft), row saves, CSV import, and the budget-vs-actual computation. Actual figures come from one new read-only function in the one query layer, reporting.Actuals; reporting never reads a budget table. Budget routes join the existing internal/modules/accounting (no new module, no internal/app or cmd wiring); the report joins internal/modules/reports and the export pipeline in internal/platform/reporting/export. The web gains web/src/features/accounting/budgets and a Budget vs actual tab on /reports. Nothing here posts, registers a posting rule or touches journal_*.

Tech Stack: Go 1.26 · Gin · pgx v5 (hand-written SQL) · goose · oapi-codegen · testify · testcontainers (Postgres 17) · shopspring/decimal behind kernel/money · encoding/csv · React 19 · TanStack Router/Query/Table · i18next (en/ms/zh) · Vitest + MSW · Playwright

Spec: docs/superpowers/specs/2026-09-27-budgets-design.md (decisions B1–B14)

Every task’s requirements implicitly include this section.

  • Migrations 00370–00379 only. This plan uses 00370–00372. Task 1 registers {370, 379, "Phase 2 budgets"} in internal/kernel/db/migrationranges_test.go in the same commit as 00370.
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (the OpenAPI Money schema). Compare with Cmp, never ==. Percentages are strings from money.PercentOf. No float64 in internal/platform/accounting/budget, the new handlers, or web/src/features/accounting/budgets (web/src/drift/nofloat.test.ts scans the web; percentOf in web/src/lib/money.ts returns a number and must not be used for % used — the server sends it).
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, a CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite (org_id, x) foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Named constraints. Every CHECK/UNIQUE/FOREIGN KEY is CONSTRAINT <table>_<rule>; no name ends in _check, _key or _excl (TestEveryConstraintIsNamed). A trigger’s refusal is RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name.
  • Pinned functions. Every trigger function is SET search_path = pg_catalog, public, pg_temp and schema-qualifies every table (public.budget_version) (TestEveryFunctionPinsItsSearchPath). No SECURITY DEFINER.
  • Every SQL statement names org_id = $n for each org-scoped table it touches, join equalities included, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org from tenancy.OrgFromContext; nothing takes an org argument.
  • Retention. budget is appendOnly (REVOKE UPDATE, DELETE: never changed after creation); budget_version and budget_line are amendable (REVOKE DELETE) in internal/kernel/db/retention_test.go (spec B13).
  • Not the ledger. Nothing in this plan imports accounting/ledger outside tests, writes journal_*, or calls accounting.RegisterPostingRule. reporting gains read-only code only (TestReportingNeverWrites keeps passing).
  • Lock order: budget_version row FOR UPDATE (revision check) → cell writes → audit.Write, last. Revise takes no lock: budget has no UPDATE grant, so it cannot be row-locked; the one-draft unique index serializes two revisions. No advisory locks.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers open transactions. Reads run in db.InReadOnlyTx.
  • Business date is clock.TodayUTC(c); timestamps are c.Now().UTC(); never time.Now() in code under test. Every DATE in Go is midnight UTC.
  • Errors are RFC 7807. Handlers c.Error(apperr.New(kind, detail)) and return. Platform code returns sentinel or typed errors, never *apperr.Problem. A field refusal is 422 with detail field=<name> rule=<code>: <sentence> and .With("code", <code>).
  • Every route goes through httpx.Protected with its permission and is documented in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces); routes_test.go of the module pins each route’s permission. api/public_routes.txt gains nothing. make generate after every migration, OpenAPI or constants.yaml change; generated files are committed and git status is clean after it.
  • Vocabulary values are strings: version status draft | approved | discarded; report budget_vs_actual; section income | expense.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/budgets.json or the existing reports.json/shell.json; web/src/drift/i18nparity.test.ts requires identical keys and placeholders. Malay is genuine Malay — use the strings in this plan.
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first. Every guard (trigger, CHECK, Go rule) is watched failing once: each such task has a Sabotage step — break the guard, run the test, read the red message, restore, and record the message in the commit body.
  • Tests: testdb.Start (through accountingtest.New) per test; run only the touched packages while iterating (go test ./internal/platform/accounting/budget/... -count=1); one full go test -p 2 -count=1 ./... in Task 10 only.
  • e2e lane 5: Postgres 56462, MinIO 59150, api 3150, vite 5196. Never the user’s dev stack (3000/5173/55432/59000).
  • Never bare git stash. Use a WIP commit.
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Places the spec is silent or leaves a choice to the plan. Format: Ruling: <decision> — <why>.

  • R1 Ruling: Package internal/platform/accounting/budget (package name budget); test world helpers live in its own _test.go files on top of accountingtest.Env and reportingtest.Seed — the golden books already hold income, expense and analytic shares in FY2026, which is exactly what budget-vs-actual needs. No budgettest package: nothing outside this package and modules/accounting builds budgets, and the module test builds them through HTTP.
  • R2 Ruling: The wire and Go shape of a version’s cells is rows: {account_id, analytic_account_id|null, amounts: [12]Money} indexed by period seq 1..12. The table stores one row per cell (budget_line), so a period FK is possible; rows are assembled in Go.
  • R3 Ruling: A save writes all 12 cells of each row sent by INSERT … ON CONFLICT ON CONSTRAINT budget_line_cell DO UPDATE SET amount = EXCLUDED.amount, updated_at = EXCLUDED.updated_at WHERE budget_line.amount <> EXCLUDED.amount; the returned rows are the changed cells (for the audit row). A save with no changed cell still bumps revision (the caller’s ETag moves on every accepted write).
  • R4 Ruling: The same (account, analytic account) twice in one request is 422 duplicate_row. amounts must have exactly 12 entries (422 period_count). Each amount parses with money.Parse and must be ≥ 0 (422 negative_amount).
  • R5 Ruling (B4 overlap): checked against the version’s rows after the save (existing rows ∪ sent rows, per account); rows whose 12 amounts are all zero are ignored, so zeroing a parent row lets a child row in. 422 analytic_overlap names both codes.
  • R6 Ruling: A budget’s name is 1–120 characters after trim, unique per fiscal year ignoring case (409 duplicate_name). It is not record text (no statutory record carries it), so no recordtext check.
  • R7 Ruling: axis_id on a budget must name an active axis whose applies_to includes income or expense (422 axis_not_applicable); a row’s account type must be in the axis’s applies_to (422 axis_not_applicable).
  • R8 Ruling: Refusal codes, one list: name_required, duplicate_name (409), fiscal_year_not_found (422), axis_not_found (422), axis_not_applicable, account_not_found, account_not_budgetable (not postable, or not income/expense), analytic_required, analytic_not_allowed, analytic_other_axis, analytic_overlap, duplicate_row, period_count, negative_amount, version_not_draft (409), draft_exists (409), revision_mismatch (409, the payables text). CSV adds csv_header, csv_row (422 with an errors array).
  • R9 Ruling: The ETag of a version is its revision, sent as ETag: "<n>" on every version response; writes require If-Match (400 without it). The accounting module copies ifMatch/setETag from internal/modules/payables/http.go into its own http.go (module-local, like its permissionLoader).
  • R10 Ruling: Approving or discarding requires If-Match too, so a member cannot approve a draft whose cells changed after they last read it.
  • R11 Ruling: GET /accounting/budgets without fiscal_year_id lists every budget, newest fiscal year first then name; unpaginated, capped at 500 (an organization has a handful a year).
  • R12 Ruling: The version response carries the fiscal year’s 12 periods (seq, starts_on, ends_on, status) so the grid can label columns without a second request, and each row’s account code and label (translated, accountLabels-style) and analytic code/name.
  • R13 Ruling (B11 dates): from must equal a period’s starts_on and to a period’s ends_on, both periods of the budget’s fiscal year, from ≤ to; otherwise 422 period_alignment. The web offers period selects, so a user never types these.
  • R14 Ruling: reporting.Actuals takes an analytic-account list only on one axis; the budget package passes the version’s distinct analytic accounts. With none (no-axis budget) it returns per-account movements only. Types are fixed to income and expense; closing entries are always excluded (R5 of Plan 9).
  • R15 Ruling: Report rows are ordered by section (income, then expense), then account code, then analytic code. Row identity on the wire: account (the reports module’s accountDTO) and analytic_account {id, code, name} | null.
  • R16 Ruling: percent_used has one decimal ("120.0"); null when the budget is zero, including when the actual is non-zero (an unbudgeted row). The web shows “—” for null.
  • R17 Ruling: The export’s header block gains Budget (name), Version (“Original” / “Revision n”, and its status in words), Analytic axis (name) when set, and Rows (the B11 sentence for the budget’s kind). Columns: Section, Code, Account, [Analytic], Budget, Actual, Variance, % used. Totals rows per section and a Net row, bold.
  • R18 Ruling (merge order): migration 00372 rebuilds report_export_report_known. Its list is the list on the integration branch when this item merges plus budget_vs_actual. If AR (00310–00329) or another item added a report first, include theirs; the Down restores exactly that list. export.go/params.go/project.go/the reports module’s mapErr message/ReportsPage.tsx tab list/routes.tsx enum are shared edit points with those items: merge, never overwrite.
  • R19 Ruling (e2e ports): scripts/dev-stack.sh and web/playwright.config.ts hardcode the dev ports. Task 10 parameterizes them by environment (PG_PORT, MINIO_PORT, API_PORT, WEB_PORT, STACK_NAME), defaults unchanged. If an earlier-merged Phase 2 lane already did this, use theirs and skip the step.
  • R20 Ruling: No activity-feed rows: a budget is not a member’s own request (the payables P33 precedent). Audit only.
  • R21 Ruling: CSV import writes through the same SaveRows code path (one validation and one audit shape), with audit.Entry.Action = "import" and After carrying sha256, rows and changed.

File Task Responsibility
00370_budget.sql 1 budget, budget_version, budget_line; guards; RLS; grants.
00371_budget_grants.sql 6 Existing Owners get accounting.budget.manage + .approve; existing Finance roles get .manage.
00372_report_export_budget_vs_actual.sql 7 report_export_report_known gains budget_vs_actual (R18).
Path Task(s) Responsibility
internal/platform/accounting/budget/doc.go 3 Package doc, statuses, errors (FieldError, RevisionMismatchError, sentinels).
internal/platform/accounting/budget/budget.go 3 Create, Get, List, GetVersion, Revise, Approve, Discard.
internal/platform/accounting/budget/rows.go 3 SaveRows, validation, overlap check.
internal/platform/accounting/budget/csv.go 4 ParseCSV (pure), ImportCSV.
internal/platform/accounting/budget/vsactual.go 5 VsActual, CheckVsActual.
internal/platform/reporting/actuals.go 2 reporting.Actuals.
internal/modules/accounting/budget_handlers.go 6 Nine routes.
internal/modules/reports/budget_handlers.go 7 GET /reports/budget-vs-actual.
web/src/features/accounting/budgets/ 8 api.ts, BudgetsPage.tsx, NewBudgetDialog.tsx, BudgetPage.tsx, BudgetGrid.tsx, grid.ts, csv.ts, routes.tsx, tests.
web/src/features/reports/BudgetVsActualView.tsx 9 The report tab.
web/e2e/budgets.spec.ts 10 The e2e.
Path Task Change
internal/kernel/db/migrationranges_test.go 1 {370, 379, "Phase 2 budgets"}
internal/kernel/db/retention_test.go 1 Three tables into their lists.
internal/kernel/money/percent.go (+ _test) 2 PercentOf.
constants.yaml (+ generated vocab) 6 Two permissions.
internal/modules/accounting/{permissions.go,routes.go,http.go,errors.go,routes_test.go} 6 Permissions, routes, ifMatch/setETag, mapBudgetErr.
internal/platform/reporting/export/{export.go,params.go,project.go} (+ tests) 7 budget_vs_actual.
internal/modules/reports/{routes.go,errors.go,export_handlers.go,routes_test.go} 7 Route, error mapping, params DTO.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 6, 7 Budget paths and schemas; report path; export enum and param.
web/src/app/shell/nav.ts, web/src/app/routes.ts, web/src/lib/i18n/locales/*/budgets.json (+ i18n namespace registration) 8 Nav item, routes, strings.
web/src/features/reports/{ReportsPage.tsx,routes.tsx,ExportMenu.tsx?}, locales/*/reports.json 9 Tab, search params, export params.
scripts/dev-stack.sh, web/playwright.config.ts, web/vite.config.ts, web/e2e/support.ts 10 Port parameters (R19); ensureFinanceBudgets.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 10 Tick; deploy notes.

Tasks in one wave touch disjoint files and may run in parallel.

Wave Tasks Why this order
1 T1 ∥ T2 Schema vs. money + reporting; disjoint.
2 T3 Needs T1’s tables.
3 T4 ∥ T5 Both on T3’s package, disjoint files (csv.go vs vsactual.go); T5 also needs T2.
4 T6 Routes need T3 + T4; first openapi.yaml edit.
5 T7 ∥ T8 T7: reports + export + openapi.yaml (needs T5, T6); T8: web budgets (needs T6’s generated schema; edits no generated file).
6 T9 Web report tab; needs T7’s schema.
7 T10 e2e, docs, full suite.

Task 1: Schema — budget, budget_version, budget_line and their guards

Section titled “Task 1: Schema — budget, budget_version, budget_line and their guards”

Files:

  • Create: db/migrations/00370_budget.sql
  • Create: internal/platform/accounting/budget/schema_test.go
  • Modify: internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go

Interfaces:

  • Consumes: fiscal_year/fiscal_period (00050, UNIQUE (org_id, fiscal_year_id, id) on fiscal_period), analytic_axis, analytic_account (UNIQUE (org_id, axis_id, id)), account, organization_member.

  • Produces: the three tables and the constraint names Go maps: budget_name_per_year, budget_version_one_draft, budget_version_frozen, budget_line_version_is_draft, budget_line_axis_matches_budget, budget_line_cell_fixed, budget_line_amount_nonnegative, budget_line_cell.

  • Step 1: Write the failing schema tests. internal/platform/accounting/budget/schema_test.go (package budget_test), on accountingtest.New(t) + SeedBooks, inserting through e.Tx with raw SQL (this test is about the database, not Go):

// Presence, then absence, for each guard. Rows are inserted as the app role
// inside the org's transaction, so RLS is in force.
func TestAnApprovedVersionRefusesCells(t *testing.T) {
e := accountingtest.New(t)
b := e.SeedBooks(t)
m := e.SeedMember(t, "Budgeter")
bud, v := insertBudget(t, e, b.Year.ID, nil, m) // helper below: budget + version 1 draft
// PRESENCE: a draft takes a cell.
insertCell(t, e, bud, v, b.Year.ID, b.Periods[0].ID, b.Chart.Travel.ID, nil, nil, "100")
approve(t, e, v, m)
// ABSENCE: the approved version takes none, and its existing cell cannot change.
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
return execCell(ctx, tx, e.Org, bud, v, b.Year.ID, b.Periods[1].ID, b.Chart.Travel.ID, nil, nil, "5")
})
requireConstraint(t, err, "budget_line_version_is_draft")
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE budget_line SET amount = 1 WHERE org_id = $1 AND budget_version_id = $2`, e.Org, v)
return err
})
requireConstraint(t, err, "budget_line_version_is_draft")
}

Also write, each presence-first:

  • TestAnApprovedVersionRowIsFrozen — updating a draft’s revision succeeds; after approval, UPDATE budget_version SET revision = revision + 1 fails budget_version_frozen; so does moving a discarded version back to draft.
  • TestOneDraftPerBudget — a second version as approved inserts; a second draft fails on index budget_version_one_draft (SQLSTATE 23505).
  • TestACellFollowsItsBudgetsAxis — on a budget with axis cost_centre: a cell with (axis_id, analytic_account_id) = (cost_centre, HQ) inserts; with (project, ProjA) fails budget_line_axis_matches_budget; with both null fails budget_line_axis_matches_budget. On a budget with no axis: both null inserts; (cost_centre, HQ) fails budget_line_axis_matches_budget.
  • TestACellCannotMoveToAnotherCell — updating amount on a draft succeeds; updating account_id fails budget_line_cell_fixed.
  • TestACellIsInItsBudgetsYear — a cell whose fiscal_period_id is a period of FY2027 (fiscal.CreateYear 2027-01-01) while the budget is FY2026 fails with SQLSTATE 23503 on budget_line_period_fk.
  • TestACellIsNotNegative — 0 inserts; -0.01 fails budget_line_amount_nonnegative.
  • TestACellIsUniquePerVersionAccountAnalyticPeriod — a second identical cell with null analytic fails budget_line_cell (the NULLS NOT DISTINCT); presence: the same account in another period inserts.

requireConstraint asserts *pgconn.PgError with ConstraintName == name (copy the helper shape from internal/platform/payables/schema_test.go if present there, else write it: var pgErr *pgconn.PgError; require.ErrorAs(t, err, &pgErr); require.Equal(t, name, pgErr.ConstraintName)).

Run: go test ./internal/platform/accounting/budget/... -count=1 → FAIL (relation “budget” does not exist).

  • Step 2: The migration. db/migrations/00370_budget.sql:
-- +goose Up
-- Phase 2 budgets (spec B1–B14). Not ledger data: nothing here posts.
CREATE TABLE budget (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
fiscal_year_id UUID NOT NULL,
name TEXT NOT NULL CONSTRAINT budget_name_length CHECK (btrim(name) <> '' AND length(name) <= 120),
axis_id UUID,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT budget_tenant_id UNIQUE (org_id, id),
-- The target of budget_line's key, which ties a cell's period to the budget's year.
CONSTRAINT budget_year_id UNIQUE (org_id, fiscal_year_id, id),
CONSTRAINT budget_fiscal_year_fk FOREIGN KEY (org_id, fiscal_year_id) REFERENCES fiscal_year (org_id, id),
CONSTRAINT budget_axis_fk FOREIGN KEY (org_id, axis_id) REFERENCES analytic_axis (org_id, id),
CONSTRAINT budget_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id)
);
CREATE UNIQUE INDEX budget_name_per_year ON budget (org_id, fiscal_year_id, lower(btrim(name)));
CREATE TABLE budget_version (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
budget_id UUID NOT NULL,
version_no INT NOT NULL CONSTRAINT budget_version_no_positive CHECK (version_no >= 1),
status TEXT NOT NULL CONSTRAINT budget_version_status_known CHECK (status IN ('draft','approved','discarded')),
revision INT NOT NULL DEFAULT 1 CONSTRAINT budget_version_revision_positive CHECK (revision >= 1),
based_on_id UUID,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
approved_by UUID,
approved_at TIMESTAMPTZ,
discarded_by UUID,
discarded_at TIMESTAMPTZ,
CONSTRAINT budget_version_tenant_id UNIQUE (org_id, id),
CONSTRAINT budget_version_budget_id UNIQUE (org_id, budget_id, id),
CONSTRAINT budget_version_number UNIQUE (org_id, budget_id, version_no),
CONSTRAINT budget_version_budget_fk FOREIGN KEY (org_id, budget_id) REFERENCES budget (org_id, id),
CONSTRAINT budget_version_based_on_fk FOREIGN KEY (org_id, budget_id, based_on_id) REFERENCES budget_version (org_id, budget_id, id),
CONSTRAINT budget_version_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id),
CONSTRAINT budget_version_approved_by_fk FOREIGN KEY (org_id, approved_by) REFERENCES organization_member (org_id, id),
CONSTRAINT budget_version_discarded_by_fk FOREIGN KEY (org_id, discarded_by) REFERENCES organization_member (org_id, id),
CONSTRAINT budget_version_approval_matches_status
CHECK ((status = 'approved') = (approved_by IS NOT NULL AND approved_at IS NOT NULL)),
CONSTRAINT budget_version_discard_matches_status
CHECK ((status = 'discarded') = (discarded_by IS NOT NULL AND discarded_at IS NOT NULL))
);
-- B7: one draft per budget.
CREATE UNIQUE INDEX budget_version_one_draft ON budget_version (org_id, budget_id) WHERE status = 'draft';
CREATE TABLE budget_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
budget_id UUID NOT NULL,
budget_version_id UUID NOT NULL,
fiscal_year_id UUID NOT NULL,
fiscal_period_id UUID NOT NULL,
account_id UUID NOT NULL,
axis_id UUID,
analytic_account_id UUID,
amount NUMERIC(19,4) NOT NULL CONSTRAINT budget_line_amount_nonnegative CHECK (amount >= 0),
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT budget_line_tenant_id UNIQUE (org_id, id),
CONSTRAINT budget_line_cell UNIQUE NULLS NOT DISTINCT
(org_id, budget_version_id, account_id, analytic_account_id, fiscal_period_id),
CONSTRAINT budget_line_analytic_pair CHECK ((axis_id IS NULL) = (analytic_account_id IS NULL)),
CONSTRAINT budget_line_version_fk FOREIGN KEY (org_id, budget_id, budget_version_id)
REFERENCES budget_version (org_id, budget_id, id),
CONSTRAINT budget_line_budget_fk FOREIGN KEY (org_id, fiscal_year_id, budget_id)
REFERENCES budget (org_id, fiscal_year_id, id),
CONSTRAINT budget_line_period_fk FOREIGN KEY (org_id, fiscal_year_id, fiscal_period_id)
REFERENCES fiscal_period (org_id, fiscal_year_id, id),
CONSTRAINT budget_line_account_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id),
CONSTRAINT budget_line_analytic_fk FOREIGN KEY (org_id, axis_id, analytic_account_id)
REFERENCES analytic_account (org_id, axis_id, id)
);
ALTER TABLE budget ENABLE ROW LEVEL SECURITY;
ALTER TABLE budget FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON budget
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE budget_version ENABLE ROW LEVEL SECURITY;
ALTER TABLE budget_version FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON budget_version
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE budget_line ENABLE ROW LEVEL SECURITY;
ALTER TABLE budget_line FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON budget_line
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- B13: never deleted. A budget is never changed after creation.
REVOKE UPDATE, DELETE ON budget FROM erp_app;
REVOKE DELETE ON budget_version, budget_line FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION budget_version_frozen() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF OLD.status <> 'draft'
OR NEW.budget_id <> OLD.budget_id OR NEW.version_no <> OLD.version_no
OR NEW.based_on_id IS DISTINCT FROM OLD.based_on_id
OR NEW.created_by <> OLD.created_by OR NEW.created_at <> OLD.created_at THEN
RAISE EXCEPTION 'budget version % is not an editable draft', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'budget_version_frozen';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER budget_version_frozen BEFORE UPDATE ON budget_version
FOR EACH ROW EXECUTE FUNCTION budget_version_frozen();
-- +goose StatementBegin
CREATE FUNCTION budget_line_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
v_status TEXT;
b_axis UUID;
BEGIN
IF TG_OP = 'UPDATE' AND (NEW.budget_id <> OLD.budget_id OR NEW.budget_version_id <> OLD.budget_version_id
OR NEW.fiscal_year_id <> OLD.fiscal_year_id OR NEW.fiscal_period_id <> OLD.fiscal_period_id
OR NEW.account_id <> OLD.account_id OR NEW.axis_id IS DISTINCT FROM OLD.axis_id
OR NEW.analytic_account_id IS DISTINCT FROM OLD.analytic_account_id) THEN
RAISE EXCEPTION 'budget cell % cannot move to another cell', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'budget_line_cell_fixed';
END IF;
SELECT v.status INTO v_status FROM public.budget_version v
WHERE v.org_id = NEW.org_id AND v.id = NEW.budget_version_id;
IF v_status IS DISTINCT FROM 'draft' THEN
RAISE EXCEPTION 'budget version % is not a draft', NEW.budget_version_id
USING ERRCODE = '23514', CONSTRAINT = 'budget_line_version_is_draft';
END IF;
SELECT b.axis_id INTO b_axis FROM public.budget b WHERE b.org_id = NEW.org_id AND b.id = NEW.budget_id;
IF b_axis IS DISTINCT FROM NEW.axis_id THEN
RAISE EXCEPTION 'budget cell axis does not match its budget''s axis'
USING ERRCODE = '23514', CONSTRAINT = 'budget_line_axis_matches_budget';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER budget_line_guard BEFORE INSERT OR UPDATE ON budget_line
FOR EACH ROW EXECUTE FUNCTION budget_line_guard();
-- +goose Down
DROP TABLE budget_line;
DROP TABLE budget_version;
DROP TABLE budget;
DROP FUNCTION budget_line_guard();
DROP FUNCTION budget_version_frozen();

Note on budget_version_frozen: the approve and discard transitions are UPDATEs from draft, so OLD.status = 'draft' lets them through; everything after is frozen. revision may change only while draft.

  • Step 3: Ranges and retention. migrationranges_test.go gains {370, 379, "Phase 2 budgets"}. retention_test.go: "budget" into appendOnly with the comment // Phase 2 budgets (B13): created once, never changed.; "budget_version", "budget_line" into amendable with // Phase 2 budgets (B13): drafts change, approved versions are frozen by trigger; never deleted.

  • Step 4: Run. make generate (sqlc/vocab regenerate cleanly; nothing new generated here) then go test ./internal/platform/accounting/budget/... ./internal/kernel/db/... -count=1 → PASS. The RLS drift test (kernel/db/rlscoverage_test.go) covers the new tables without edits — confirm it lists them by temporarily running it with -v -run RLS and reading that its checked-table count rose by 3.

  • Step 5: Sabotage. (a) Comment out the v_status check in budget_line_guard in a scratch migration edit, rerun TestAnApprovedVersionRefusesCells, read the red message (the insert succeeds), restore. (b) Remove NULLS NOT DISTINCT, rerun TestACellIsUniquePerVersionAccountAnalyticPeriod, read red, restore. (c) Remove "budget_line" from amendable, run TestOnlyOperationalTablesGrantDeleteToTheAppRole, read the “no list names” failure, restore. Record the three messages in the commit body.

  • Step 6: Commit. git add -A && git commit -m "Budgets Task 1: budget, budget_version, budget_line and their guards" -m "<sabotage messages>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 2: money.PercentOf and reporting.Actuals

Section titled “Task 2: money.PercentOf and reporting.Actuals”

Files:

  • Create: internal/kernel/money/percent.go, internal/kernel/money/percent_test.go
  • Create: internal/platform/reporting/actuals.go, internal/platform/reporting/actuals_test.go
  • Modify: internal/platform/reporting/scope.go (extract the descendant walk)

Interfaces:

  • Consumes: movements, movementQuery, resolveScope, loadChart, baseCurrency, provisional (all in internal/platform/reporting); reportingtest.Seed.
  • Produces:
kernel/money
func PercentOf(part, whole Amount) (string, error) // part × 100 / whole, 1 dp, half away from zero; ErrDivideByZero
// platform/reporting
type ActualsParams struct {
Range Range
AnalyticAccountIDs []uuid.UUID // one axis; each counts itself and its descendants
}
type AnalyticKey struct{ AccountID, AnalyticAccountID uuid.UUID }
type Actuals struct {
Currency string
Provisional bool
Accounts map[uuid.UUID]AccountRef // every postable income/expense account
ByAccount map[uuid.UUID]money.Amount // presentation sign (R10); only accounts that moved
ByAnalytic map[AnalyticKey]money.Amount // only when AnalyticAccountIDs is non-empty
}
func Actuals(ctx context.Context, tx pgx.Tx, p ActualsParams) (Actuals, error)
  • Step 1: PercentOf test first. percent_test.go: PercentOf(1200, 1000) = "120.0"; (1, 3) = "33.3"; (2, 3) = "66.7"; (0.05, 100) = "0.1" (0.05 → half away from zero); (-10, 100) = "-10.0"; (0, 5) = "0.0"; (x, 0) → ErrDivideByZero. Run → FAIL (undefined).
package money
import "github.com/shopspring/decimal"
// PercentOf is part as a percentage of whole, to one decimal place, rounded
// half away from zero, as a string — the one way a percentage of money is
// computed and sent, so no layer divides amounts in floating point.
func PercentOf(part, whole Amount) (string, error) {
if whole.d.IsZero() {
return "", ErrDivideByZero
}
return part.d.Mul(decimal.New(100, 0)).DivRound(whole.d, 1).StringFixed(1), nil
}

(decimal.DivRound rounds half away from zero — check the test case 0.05/100 → "0.1"; if it rounds half-even, the test says so and you use Div(...).Round(1) with the same test.)

  • Step 2: Actuals tests first, on reportingtest.Seed(t, accountingtest.New(t), reportingtest.Options{}) (the golden books, table in Plan 9 Task 6):
func TestActualsAreTheProfitAndLossFigures(t *testing.T) {
g := reportingtest.Seed(t, accountingtest.New(t), reportingtest.Options{})
fy := reporting.Range{From: reportingtest.Date("2026-01-01"), To: reportingtest.Date("2026-12-31")}
var a reporting.Actuals
var pl reporting.ProfitAndLossReport
g.Env.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
if a, err = reporting.Actuals(ctx, tx, reporting.ActualsParams{Range: fy}); err != nil {
return err
}
pl, err = reporting.ProfitAndLoss(ctx, tx, reporting.ProfitAndLossParams{Range: fy})
return err
})
// PRESENCE: the golden books moved income and expense in FY2026.
require.NotEmpty(t, a.ByAccount)
inc, exp := money.Zero(), money.Zero()
for id, v := range a.ByAccount {
switch a.Accounts[id].Type {
case chart.TypeIncome:
inc = inc.Add(v)
case chart.TypeExpense:
exp = exp.Add(v)
}
}
require.Zero(t, inc.Cmp(pl.TotalIncome), "income %s vs P&L %s", inc, pl.TotalIncome)
require.Zero(t, exp.Cmp(pl.TotalExpenses), "expense %s vs P&L %s", exp, pl.TotalExpenses)
// Sign (R10): Travel March 1200 is +1200, Revenue Feb 5000 is +5000.
require.Equal(t, "5000.0000", a.ByAccount[g.Books.Chart.Revenue.ID].String())
}

Also:

  • TestActualsByAnalyticAccountAreStoredShares — range 2026-02-01..2026-03-31, AnalyticAccountIDs = [HQ, Ops]: ByAnalytic[{Revenue, HQ}] = 3000, {Revenue, Ops} = 2000, {Travel, HQ} = 1200, {Meals, Ops} = 300.5; {Travel, Ops} absent (presence of the other three first).
  • TestActualsIncludeDescendants — create child HQ-1 under HQ (analytic.CreateAccount with ParentID), post Dr Travel 50 with 100% HQ-1 on 2026-03-25; ByAnalytic[{Travel, HQ}] = 1250 and requesting HQ-1 alone gives 50.
  • TestActualsExcludeTheClosingEntry — Options{CloseFY2026: true}: FY2026 ByAccount equals the unclosed seed’s figures (Revenue still 5000).
  • TestActualsRefuseMixedAxes — [HQ, ProjA] → ErrAnalyticAxesMixed (presence: [HQ, Ops] succeeds, above).
  • TestActualsReportProvisional — FY2026 open: Provisional true; with CloseFY2026, a range inside FY2026: false.

Run → FAIL (undefined: reporting.Actuals).

  • Step 3: Extract the descendant walk from resolveScope in scope.go into
// descendantsOf returns, for each requested id, itself and every descendant on
// its axis. All ids must exist and share one axis (ErrAnalyticAccountNotFound,
// ErrAnalyticAxesMixed). resolveScope's analyticIDs is the union of the values.
func descendantsOf(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID][]uuid.UUID, error)

resolveScope calls it and unions the values (same order-insensitive set it built before). Run the existing reporting tests (go test ./internal/platform/reporting/ -count=1) → PASS unchanged: a pure refactor.

  • Step 4: actuals.go.
package reporting
// actualsByAnalyticSQL: each line's stored shares on the requested analytic
// accounts (Ruling R8), signed debit-positive, per (account, analytic account).
const actualsByAnalyticSQL = `
SELECT l.account_id, d.analytic_account_id,
coalesce(sum(CASE WHEN l.base_debit > 0 THEN d.base_amount ELSE -d.base_amount END), 0)
FROM analytic_distribution d
JOIN journal_line l ON l.org_id = $1 AND l.id = d.journal_line_id
JOIN journal_entry e ON e.org_id = $1 AND e.id = l.entry_id
JOIN account a ON a.org_id = $1 AND a.id = l.account_id
WHERE d.org_id = $1 AND l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1
AND d.analytic_account_id = ANY($4)
AND e.entry_date >= $2 AND e.entry_date <= $3
AND e.semantic <> 'year_end_close'
AND a.type IN ('income', 'expense')
GROUP BY l.account_id, d.analytic_account_id`
// Actuals is the P&L's figures in the shape a budget comparison needs:
// per account, and per (account, analytic account incl. descendants).
// Read-only; the caller's transaction (db.InReadOnlyTx).
func Actuals(ctx context.Context, tx pgx.Tx, p ActualsParams) (Actuals, error) {
if err := p.Range.Validate(); err != nil {
return Actuals{}, err
}
cv, err := loadChart(ctx, tx)
if err != nil {
return Actuals{}, err
}
from := p.Range.From
m, err := movements(ctx, tx, movementQuery{From: &from, To: p.Range.To, ExcludeClosing: true,
Types: []string{chart.TypeIncome, chart.TypeExpense}})
if err != nil {
return Actuals{}, err
}
out := Actuals{Accounts: map[uuid.UUID]AccountRef{}, ByAccount: map[uuid.UUID]money.Amount{},
ByAnalytic: map[AnalyticKey]money.Amount{}}
for _, a := range cv.postableByCode() {
if a.Type == chart.TypeIncome || a.Type == chart.TypeExpense {
out.Accounts[a.ID] = cv.ref(a.ID)
}
}
sign := func(id uuid.UUID, debitPositive money.Amount) money.Amount {
if cv.byID[id].Type == chart.TypeIncome {
return debitPositive.Neg()
}
return debitPositive
}
for id, mv := range m {
out.ByAccount[id] = sign(id, mv.net())
}
if len(p.AnalyticAccountIDs) > 0 {
desc, err := descendantsOf(ctx, tx, p.AnalyticAccountIDs)
if err != nil {
return Actuals{}, err
}
// leaf share id -> the requested ids it rolls up to
up := map[uuid.UUID][]uuid.UUID{}
var all []uuid.UUID
for req, ids := range desc {
for _, id := range ids {
if len(up[id]) == 0 {
all = append(all, id)
}
up[id] = append(up[id], req)
}
}
orgID, _ := tenancy.OrgFromContext(ctx) // loadChart already required it
rows, err := tx.Query(ctx, actualsByAnalyticSQL, orgID, p.Range.From, p.Range.To, all)
if err != nil {
return Actuals{}, fmt.Errorf("reporting: actuals: %w", err)
}
defer rows.Close()
for rows.Next() {
var acc, aa uuid.UUID
var v money.Amount
if err := rows.Scan(&acc, &aa, &v); err != nil {
return Actuals{}, fmt.Errorf("reporting: actuals: %w", err)
}
for _, req := range up[aa] {
k := AnalyticKey{AccountID: acc, AnalyticAccountID: req}
out.ByAnalytic[k] = lookupAmount(out.ByAnalytic, k).Add(sign(acc, v))
}
}
if err := rows.Err(); err != nil {
return Actuals{}, fmt.Errorf("reporting: actuals: %w", err)
}
}
if out.Currency, err = baseCurrency(ctx, tx); err != nil {
return Actuals{}, err
}
if out.Provisional, err = provisional(ctx, tx, &from, p.Range.To); err != nil {
return Actuals{}, err
}
return out, nil
}

(lookupAmount returns the map value or money.Zero(); write it beside lookup in movements.go. loadChart does not itself read the org — check; if it does not, read tenancy.OrgFromContext at the top and return tenancy.ErrNoOrg like movements does, instead of the _.)

  • Step 5: Run. go test ./internal/kernel/money/... ./internal/platform/reporting/... -count=1 → PASS, including TestReportingNeverWrites.

  • Step 6: Sabotage. Change ELSE -d.base_amount to ELSE d.base_amount; run TestActualsByAnalyticAccountAreStoredShares → red (Revenue HQ reads −3000). Drop AND e.semantic <> 'year_end_close'; run TestActualsExcludeTheClosingEntry → red. Restore; record both.

  • Step 7: Commit. "Budgets Task 2: money.PercentOf and reporting.Actuals".


Task 3: The budget package — lifecycle and row saves

Section titled “Task 3: The budget package — lifecycle and row saves”

Files:

  • Create: internal/platform/accounting/budget/{doc.go,budget.go,rows.go}
  • Create: internal/platform/accounting/budget/{world_test.go,budget_test.go,rows_test.go}

Interfaces:

  • Consumes: fiscal.GetYear, fiscal.ListPeriods, analytic.GetAxis, analytic.ListAccounts, chart.GetAccount / chart.ListAccounts, audit.Write, tenancy.OrgFromContext.
  • Produces:
package budget
const (
StatusDraft = "draft"
StatusApproved = "approved"
StatusDiscarded = "discarded"
)
type Budget struct {
ID, FiscalYearID uuid.UUID
Name string
AxisID *uuid.UUID
CreatedBy uuid.UUID
CreatedAt time.Time
Versions []Version // ascending version_no; filled by Get/List
}
type Version struct {
ID, BudgetID uuid.UUID
No, Revision int
Status string
BasedOnID *uuid.UUID
CreatedBy uuid.UUID
CreatedAt time.Time
ApprovedBy, DiscardedBy *uuid.UUID
ApprovedAt, DiscardedAt *time.Time
}
// Current is the highest-numbered approved version, or nil.
func (b Budget) Current() *Version
// Draft is the draft version, or nil.
func (b Budget) Draft() *Version
type Row struct {
AccountID uuid.UUID
AnalyticAccountID *uuid.UUID
Amounts [12]money.Amount // by period seq-1
}
type VersionView struct {
Budget Budget
Version Version
Periods []fiscal.Period // the budget year's, by seq
Rows []Row // ordered by account code, then analytic code
}
type CreateInput struct {
FiscalYearID uuid.UUID
Name string
AxisID *uuid.UUID
}
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, in CreateInput, by uuid.UUID) (Budget, error)
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Budget, error)
func List(ctx context.Context, tx pgx.Tx, fiscalYearID *uuid.UUID) ([]Budget, error)
func GetVersion(ctx context.Context, tx pgx.Tx, id uuid.UUID) (VersionView, error)
func Revise(ctx context.Context, tx pgx.Tx, c clock.Clock, budgetID uuid.UUID, by uuid.UUID) (Version, error)
func Approve(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int, by uuid.UUID) (Version, error)
func Discard(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int, by uuid.UUID) (Version, error)
func SaveRows(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int, rows []Row, by uuid.UUID) (VersionView, error)
// Errors (doc.go)
var (
ErrNotFound = errors.New("budget: no such budget or version")
ErrVersionNotDraft = errors.New("budget: the version is not a draft")
ErrDraftExists = errors.New("budget: the budget already has a draft")
ErrDuplicateName = errors.New("budget: a budget of that name exists for the fiscal year")
)
type RevisionMismatchError struct{ Current int }
// FieldError is a 422: Field and Rule (R8's codes), Detail a sentence.
type FieldError struct{ Field, Rule, Detail string }
  • Step 1: Test world. world_test.go (package budget_test):
type world struct {
e *accountingtest.Env
g reportingtest.Golden
by uuid.UUID // the preparer
}
func newWorld(t *testing.T) *world {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
return &world{e: e, g: g, by: g.Member}
}
// amounts builds [12]money.Amount from "seq=amount" pairs, zero elsewhere.
func amounts(pairs ...string) [12]money.Amount
  • Step 2: Lifecycle tests (write first, all FAIL). budget_test.go:

  • TestCreateMakesADraftVersionOne — Create{FY2026, "Opex 2026", nil}: one version, No=1, Status=draft, Revision=1; an audit_log row entity_kind='budget' action='create' (read with e.H.Admin).

  • TestNamesAreUniquePerYearIgnoringCase — presence: "Opex" in FY2026 and "Opex" in FY2027 both succeed; absence: " opex " in FY2026 → ErrDuplicateName.

  • TestCreateRefusesAnAxisThatAppliesToNeitherIncomeNorExpense — create an axis with applies_to = [asset] via analytic.CreateAxis; presence: cost_centre (applies to expense) succeeds; absence: → *FieldError{Field: "axis_id", Rule: "axis_not_applicable"}.

  • TestApproveFreezesAndRevisionCopies — save rows on v1, Approve(v1, rev); Revise → v2 draft with BasedOnID = v1, its GetVersion(v2).Rows equal v1’s exactly; v1 Status=approved, ApprovedBy=by.

  • TestReviseRefusesWhileADraftExists — presence: after approving v1, Revise succeeds; absence: a second Revise → ErrDraftExists.

  • TestReviseWithNothingApprovedStartsEmpty — discard v1, Revise → v2 draft with no rows and BasedOnID = nil.

  • TestApproveAndDiscardNeedTheCurrentRevision — Approve(v, rev-1) → *RevisionMismatchError{Current: rev}; with the current revision → approved.

  • TestAFrozenVersionRefusesEverything — on an approved version: SaveRows, Approve, Discard → ErrVersionNotDraft (presence: each succeeds on a draft in the tests above).

  • TestCurrentIsTheHighestApproved — v1 approved, v2 approved, v3 draft: Current().No == 2, Draft().No == 3.

  • Step 3: Row tests (write first, FAIL). rows_test.go:

  • TestSaveRowsReplacesTheRowsSentOnly — save {Travel: P3=1000}, {Meals: P3=200}; save {Travel: P4=50} alone → Travel is P3=0, P4=50 (replaced), Meals unchanged (untouched); Revision rose by 2; the second save’s audit row After.changed lists exactly Travel P3 (1000→0) and P4 (0→50).

  • TestSaveRowsRefusesWhatCannotBeBudgeted — presence: Travel (postable expense) and Revenue (postable income) save; absence, each a *FieldError: the Expenses header (account_not_budgetable), Bank (asset, account_not_budgetable), a random uuid (account_not_found), a negative amount (negative_amount, field rows[0].amounts[2]), the same row twice (duplicate_row).

  • TestAxisBudgetRows — budget on cost_centre: presence: {Travel, HQ} saves; absence: {Travel, nil} → analytic_required; {Travel, ProjA} → analytic_other_axis. No-axis budget: {Travel, HQ} → analytic_not_allowed.

  • TestAnAncestorAndItsDescendantCannotBothBeBudgeted — create HQ-1 under HQ; presence: {Travel, HQ} and {Meals, HQ-1} save (different accounts); absence: adding {Travel, HQ-1} with a non-zero amount → analytic_overlap; after saving {Travel, HQ} as all zeros, {Travel, HQ-1} saves (R5).

  • TestSaveRowsNeedsTheCurrentRevision — stale → *RevisionMismatchError, nothing written (read back: unchanged).

Run go test ./internal/platform/accounting/budget/... -count=1 → FAIL (undefined).

  • Step 4: Implement doc.go and budget.go. Straightforward SQL, every statement with org_id = $1. The non-obvious parts:

lockVersion — every write starts here:

const lockVersionSQL = `
SELECT v.budget_id, v.version_no, v.status, v.revision
FROM budget_version v WHERE v.org_id = $1 AND v.id = $2 FOR UPDATE`
func lockVersion(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, ifRevision int) (budgetID uuid.UUID, err error) {
var no, rev int
var status string
if err = tx.QueryRow(ctx, lockVersionSQL, org, id).Scan(&budgetID, &no, &status, &rev); errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, ErrNotFound
} else if err != nil {
return uuid.Nil, fmt.Errorf("budget: lock version: %w", err)
}
if status != StatusDraft {
return uuid.Nil, ErrVersionNotDraft
}
if rev != ifRevision {
return uuid.Nil, &RevisionMismatchError{Current: rev}
}
return budgetID, nil
}

Status check before revision check: a frozen version answers “not a draft” whatever the caller’s ETag.

Revise:

// Lock the budget row (serializes two revisions; the one-draft index is the backstop),
// then insert the next version and copy the latest approved version's cells.
const reviseCopySQL = `
INSERT INTO budget_line (org_id, budget_id, budget_version_id, fiscal_year_id, fiscal_period_id,
account_id, axis_id, analytic_account_id, amount, updated_at)
SELECT l.org_id, l.budget_id, $3, l.fiscal_year_id, l.fiscal_period_id,
l.account_id, l.axis_id, l.analytic_account_id, l.amount, $4
FROM budget_line l WHERE l.org_id = $1 AND l.budget_version_id = $2`

Do not lock the budget row: every row-locking clause (FOR UPDATE, FOR SHARE) needs UPDATE privilege, and 00370 revokes it on budget. The race is closed by budget_version_one_draft instead: two concurrent revisions both pass the “no draft” read, one insert wins, the other gets 23505 on that index and maps to ErrDraftExists. Test the mapping with a pre-existing draft (above).

Map on constraint names in one mapDBErr(err error) error: budget_name_per_year → ErrDuplicateName; budget_version_one_draft → ErrDraftExists; budget_version_frozen, budget_line_version_is_draft → ErrVersionNotDraft; others pass through wrapped.

Approve / Discard: lockVersion, then UPDATE budget_version SET status = $3, approved_by = $4, approved_at = $5, revision = revision + 1 WHERE org_id = $1 AND id = $2 RETURNING … (discard sets the discarded pair), then audit.Write{EntityKind: "budget_version", Action: "approve"|"discard", Before: {"status":"draft","revision":n}, After: {"status":…}}.

  • Step 5: Implement rows.go.
// SaveRows replaces the 12 cells of each row sent (spec B9) on a draft.
func SaveRows(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int, rows []Row, by uuid.UUID) (VersionView, error) {
return saveRows(ctx, tx, c, versionID, ifRevision, rows, by, "save_rows", nil)
}
// saveRows is SaveRows and ImportCSV's one path (R21); extra is merged into the audit row's After.
func saveRows(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int,
rows []Row, by uuid.UUID, action string, extra map[string]any) (VersionView, error) {
org, ok := tenancy.OrgFromContext(ctx)
if !ok {
return VersionView{}, fmt.Errorf("budget: %w", tenancy.ErrNoOrg)
}
budgetID, err := lockVersion(ctx, tx, org, versionID, ifRevision)
if err != nil {
return VersionView{}, err
}
b, err := Get(ctx, tx, budgetID)
if err != nil {
return VersionView{}, err
}
periods, err := fiscal.ListPeriods(ctx, tx, b.FiscalYearID)
if err != nil {
return VersionView{}, fmt.Errorf("budget: periods: %w", err)
}
if err := validateRows(ctx, tx, b, rows); err != nil { // R4, R7, B4, B5
return VersionView{}, err
}
now := c.Now().UTC()
var changed []map[string]any
for _, r := range rows {
for i, p := range periods { // seq i+1
var before *money.Amount
err := tx.QueryRow(ctx, upsertCellSQL, org, b.ID, versionID, b.FiscalYearID, p.ID,
r.AccountID, b.AxisID, r.AnalyticAccountID, r.Amounts[i], now).Scan(&before)
if errors.Is(err, pgx.ErrNoRows) {
continue // unchanged: the ON CONFLICT … WHERE filtered it out
}
if err != nil {
return VersionView{}, mapDBErr(err)
}
changed = append(changed, cellChange(r, p.Seq, before, r.Amounts[i]))
}
}
if err := checkOverlap(ctx, tx, org, versionID); err != nil { // R5, after the writes
return VersionView{}, err
}
if _, err := tx.Exec(ctx, `UPDATE budget_version SET revision = revision + 1 WHERE org_id = $1 AND id = $2`,
org, versionID); err != nil {
return VersionView{}, mapDBErr(err)
}
after := map[string]any{"rows": len(rows), "changed": changed}
maps.Copy(after, extra)
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "budget_version", EntityID: versionID,
Action: action, ActorID: &by, Before: map[string]any{"revision": ifRevision}, After: after}); err != nil {
return VersionView{}, fmt.Errorf("budget: audit: %w", err)
}
return GetVersion(ctx, tx, versionID)
}
// upsertCellSQL returns the previous amount (NULL for a new cell) and no row
// when the amount did not change. The CTE reads the old value before the upsert.
const upsertCellSQL = `
WITH old AS (
SELECT l.amount FROM budget_line l
WHERE l.org_id = $1 AND l.budget_version_id = $3 AND l.account_id = $6
AND l.analytic_account_id IS NOT DISTINCT FROM $8 AND l.fiscal_period_id = $5)
INSERT INTO budget_line AS b (org_id, budget_id, budget_version_id, fiscal_year_id, fiscal_period_id,
account_id, axis_id, analytic_account_id, amount, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT ON CONSTRAINT budget_line_cell
DO UPDATE SET amount = EXCLUDED.amount, updated_at = EXCLUDED.updated_at
WHERE b.amount <> EXCLUDED.amount AND b.org_id = $1
RETURNING (SELECT amount FROM old)`

A new cell whose amount is zero is still inserted (so the row exists in the grid) but not recorded as a change: append cellChange only when before != nil || !amount.IsZero(). 12 round trips per row is fine at budget sizes (≤ 5,000 rows × 12 in the worst CSV; if Task 4’s 5,000-row import test takes over 10 s locally, batch with pgx.Batch — same SQL, same test).

validateRows loads the chart once (chart.ListAccounts) and, for an axis budget, the axis’s accounts once (analytic.ListAccounts(axisID)); it checks, in this order, returning the first *FieldError with field rows[i]…: account exists; postable and income/expense; axis presence (analytic_required / analytic_not_allowed); analytic account on the axis (analytic_other_axis — an id not in the axis’s list: look it up with analytic.GetAccount; not found at all is account_not_found on field analytic_account_id); axis applies to the account type (axis_not_applicable); amounts ≥ 0; duplicate (account, analytic) in the request.

checkOverlap reads the version’s non-zero rows (account_id, analytic_account_id) and the axis tree (analytic.ListAccounts), and for each account refuses when one row’s analytic account is an ancestor of another’s (walk ParentID up from each; small trees). Returns *FieldError{Field: "rows", Rule: "analytic_overlap", Detail: "HQ and HQ-1 on 6100 would count the same spend twice"}.

  • Step 6: Run → PASS.

  • Step 7: Sabotage. Remove the status != StatusDraft check in lockVersion: TestAFrozenVersionRefusesEverything must still fail — now on the trigger (budget_line_version_is_draft mapped to ErrVersionNotDraft), proving the backstop; read it, then also comment out the trigger’s status check in a scratch DB (rerun with the migration edited) to see the test go red; restore both. Remove checkOverlap’s call: TestAnAncestorAndItsDescendantCannotBothBeBudgeted red. Record.

  • Step 8: Commit. "Budgets Task 3: budget lifecycle and row saves".


Files:

  • Create: internal/platform/accounting/budget/csv.go, csv_test.go (pure), import_test.go (DB)

Interfaces:

  • Consumes: saveRows (Task 3), chart.ListAccounts, analytic.ListAccounts.
  • Produces:
// CSVRow is one parsed data row, by code, before resolution.
type CSVRow struct {
Line int // 1-based line in the file, header = 1
AccountCode, AnalyticCode string
Amounts [12]money.Amount
}
// CSVError lists every problem found (at most MaxCSVErrors).
type CSVError struct{ Problems []CSVProblem }
type CSVProblem struct {
Line int
Column string // "account_code", "P03", "" for the whole row
Reason string
}
const (
MaxCSVBytes = 1 << 20
MaxCSVRows = 5000
MaxCSVErrors = 50
)
func ParseCSV(text string) ([]CSVRow, error) // pure; *CSVError
func ImportCSV(ctx context.Context, tx pgx.Tx, c clock.Clock, versionID uuid.UUID, ifRevision int, text string, by uuid.UUID) (VersionView, error)
  • Step 1: Pure parser tests first (csv_test.go, table-driven, no DB):
  • header exactly account_code,analytic_code,P01,…,P12 (case-insensitive, trimmed) → ok; missing P12, extra column, reordered → one problem Line 1, Column "", "the header must be account_code,analytic_code,P01,…,P12".
  • a UTF-8 BOM before the header is dropped.
  • blank cells are zero; "1,234.50" (quoted, grouping) → 1234.50; "12.3456789" → rounds to 4 dp (money.Parse); "-5" → problem negative; "abc" → problem not a number; "1.234,50" → not a number (a comma after a point is not grouping: accept , only where ^\d{1,3}(,\d{3})*(\.\d+)?$).
  • a row with 13 fields → problem on that line, column "".
  • blank account_code → problem account_code.
  • an entirely empty line is skipped (Excel trailing lines).
  • 60 bad rows → exactly 50 problems, and the CSVError.Error() says “50 problems shown; fix these and import again”.
  • more than 5,000 data rows or more than 1 MiB → one problem, no parsing.

Use encoding/csv with FieldsPerRecord = -1 (the parser reports wrong counts itself) and TrimLeadingSpace = true.

  • Step 2: Implement ParseCSV. Run → PASS.

  • Step 3: Import tests (import_test.go, on the Task 3 world):

  • TestImportReplacesTheRowsInTheFile — the grid has Travel P03=1000 and Meals P03=200; import a file with Travel only (6100,,0,0,900,…): Travel is 900, Meals untouched; audit row action='import' has sha256 of the text and rows: 1.

  • TestImportIsAllOrNothing — presence: a file of two good rows imports; absence: two good rows and one with unknown 9999 → *CSVError whose problem names line 4, column account_code, “no account with code 9999”; the grid is unchanged (read back).

  • TestImportResolvesAnalyticCodesOnTheBudgetsAxis — axis budget on cost_centre: 6100,HQ,… imports; 6100,,… → problem analytic_code “required: this budget is by Cost centre”; 6100,PA,… (a project code) → “no Cost centre with code PA”. No-axis budget: 6100,HQ,… → “this budget has no analytic axis; leave analytic_code blank”.

  • TestImportReportsRowRulesAgainstTheirLine — a header account (6000) → line-numbered problem “not a postable income or expense account” (the FieldError from validateRows is translated back to the CSV line using the row index → CSVRow.Line).

  • Step 4: Implement ImportCSV: ParseCSV → resolve codes to ids (collect every resolution problem, capped) → build []Row (keeping a row-index → line map) → saveRows(..., "import", {"sha256": hex(sha256(text)), "file_rows": n}); a *FieldError whose field starts rows[i] becomes a CSVError problem on that row’s line.

  • Step 5: Run → PASS. Sabotage: make ParseCSV skip the header check → the reordered-header case goes red; restore. Record.

  • Step 6: Commit. "Budgets Task 4: CSV import".


Files:

  • Create: internal/platform/accounting/budget/vsactual.go, vsactual_test.go

Interfaces:

  • Consumes: reporting.Actuals, reporting.AccountRef, money.PercentOf, GetVersion.
  • Produces:
type VsActualParams struct {
VersionID uuid.UUID
Range reporting.Range
}
type AnalyticRef struct {
ID uuid.UUID
Code, Name string
}
type Figures struct {
Budget, Actual, Variance money.Amount
PercentUsed *string
}
type VsActualRow struct {
Section string // "income" | "expense"
Account reporting.AccountRef
AnalyticAccount *AnalyticRef
Figures
}
type VsActualReport struct {
Params VsActualParams
Budget Budget
Version Version
AxisName string // "" without an axis
Currency string
Provisional bool
Rows []VsActualRow
Income, Expense Figures
Net Figures // Budget/Actual = income − expense; Variance = Actual − Budget; PercentUsed nil
}
func VsActual(ctx context.Context, tx pgx.Tx, p VsActualParams) (VsActualReport, error)
// CheckVsActual validates p without computing (export requests): ErrNotFound, *FieldError{"from"|"to", "period_alignment"}.
func CheckVsActual(ctx context.Context, tx pgx.Tx, p VsActualParams) error
  • Step 1: Tests first, on the golden world (FY2026: Revenue 5000 Feb HQ60/Ops40; Travel 1200 Mar HQ; Meals 300.5 Mar Ops; Stationery 99.99 Apr reversed Apr 30; Stationery 45 May; Travel USD 100 Sep HQ/Ops 50/50 at the seeded rate):

  • TestVarianceIsFavourablePositive — no-axis budget: Travel P03=1000, Revenue P02=4000, Meals P03=400; range Feb–Mar. Travel: budget 1000, actual 1200, variance −200, "120.0"; Revenue: 4000, 5000, variance +1000, "125.0"; Meals: 400, 300.5, +99.5, "75.1" (300.5/400 = 75.125 → 75.1). Income totals 4000/5000/+1000; expense 1400/1500.5/−100.5, "107.2"; Net budget 2600, actual 3499.5, variance +899.5.

  • TestUnbudgetedSpendShowsOnANoAxisBudget — budget Travel only, range Apr–May: a Stationery row with budget 0, actual 45 (the April entry and its reversal net to 0), PercentUsed == nil; presence first: the same range with a Stationery budget of 100 shows "45.0".

  • TestRowsWithNothingAreDropped — a budgeted row of all zeros with no actual in range is not in Rows (presence: the same row with P03=1 is).

  • TestTheActualColumnIsTheProfitAndLoss — no-axis budget with any one row; range FY2026: Σ expense-row actuals == reporting.ProfitAndLoss(...).TotalExpenses, same for income (spec B2).

  • TestAnAxisBudgetComparesStoredShares — cost_centre budget: {Travel, HQ} P03=1000, {Revenue, Ops} P02=1500; range Feb–Mar: Travel/HQ actual 1200, Revenue/Ops actual 2000; no row for Revenue/HQ (not budgeted, B11) — presence: adding {Revenue, HQ} P02=0.01 makes it appear with actual 3000.

  • TestTheRangeMustBeWholePeriodsOfTheBudgetsYear — presence: 2026-02-01..2026-03-31 ok; absence (*FieldError, rule period_alignment): 2026-02-02..2026-03-31 (field from), 2026-02-01..2026-03-30 (to), 2026-12-01..2027-01-31 (to: another year), 2026-03-01..2026-02-28 (to).

  • TestADraftCanBeReported — the report of a draft version carries Version.Status == "draft" (the handler and export label it).

  • Step 2: Implement.

func VsActual(ctx context.Context, tx pgx.Tx, p VsActualParams) (VsActualReport, error) {
v, err := GetVersion(ctx, tx, p.VersionID)
if err != nil {
return VsActualReport{}, err
}
fromSeq, toSeq, err := alignedSeqs(v.Periods, p.Range) // R13
if err != nil {
return VsActualReport{}, err
}
var aas []uuid.UUID
seen := map[uuid.UUID]bool{}
for _, r := range v.Rows {
if r.AnalyticAccountID != nil && !seen[*r.AnalyticAccountID] {
seen[*r.AnalyticAccountID] = true
aas = append(aas, *r.AnalyticAccountID)
}
}
act, err := reporting.Actuals(ctx, tx, reporting.ActualsParams{Range: p.Range, AnalyticAccountIDs: aas})
if err != nil {
return VsActualReport{}, err
}
rep := VsActualReport{Params: p, Budget: v.Budget, Version: v.Version, Currency: act.Currency, Provisional: act.Provisional}
budgeted := map[uuid.UUID]bool{}
for _, r := range v.Rows {
b := money.Zero()
for s := fromSeq; s <= toSeq; s++ {
b = b.Add(r.Amounts[s-1])
}
var a money.Amount
if r.AnalyticAccountID == nil {
a = amountOr(act.ByAccount, r.AccountID)
budgeted[r.AccountID] = true
} else {
a = amountOrKey(act.ByAnalytic, reporting.AnalyticKey{AccountID: r.AccountID, AnalyticAccountID: *r.AnalyticAccountID})
}
rep.add(act.Accounts[r.AccountID], analyticRef(v, r), b, a)
}
if v.Budget.AxisID == nil { // B11: unbudgeted spend on a no-axis budget
for id, a := range act.ByAccount {
if !budgeted[id] {
rep.add(act.Accounts[id], nil, money.Zero(), a)
}
}
}
rep.finish() // drop zero/zero rows, sort (R15), section totals, net, percentages
return rep, nil
}

add sets Section from Account.Type, and Variance = Budget − Actual for expense, Actual − Budget for income. finish computes PercentUsed with money.PercentOf(actual, budget) unless the budget is zero. Net.Budget = Income.Budget − Expense.Budget, Net.Actual likewise, Net.Variance = Net.Actual − Net.Budget. AxisName from analytic.GetAxis when set. act.Accounts holds every postable income/expense account, so act.Accounts[r.AccountID] is always present for a saved row (Task 3 refused anything else).

  • Step 3: Run → PASS. Sabotage: flip the income variance sign → TestVarianceIsFavourablePositive red; skip the unbudgeted loop → TestUnbudgetedSpendShowsOnANoAxisBudget red; restore; record.

  • Step 4: Commit. "Budgets Task 5: budget-vs-actual".


Task 6: Accounting module — permissions, grants, budget routes

Section titled “Task 6: Accounting module — permissions, grants, budget routes”

Files:

  • Modify: internal/modules/accounting/{permissions.go,routes.go,http.go,errors.go,routes_test.go}
  • Create: internal/modules/accounting/budget_handlers.go, budget_handlers_test.go
  • Create: db/migrations/00371_budget_grants.sql
  • Modify: constants.yaml, api/openapi.yaml; regenerate (internal/gen/api, web/src/lib/api/schema.d.ts, vocab)

Interfaces:

  • Consumes: Task 3 and 4’s budget.*.

  • Produces: PermissionBudgetManage = "accounting.budget.manage", PermissionBudgetApprove = "accounting.budget.approve"; the nine routes of the spec’s API table; OpenAPI schemas Budget, BudgetVersion, BudgetVersionView, BudgetRow, BudgetPeriod, BudgetCreate, BudgetRowsPut, BudgetImport, BudgetList, and the 422 problem extension errors: [{line, column, reason}] for import.

  • Step 1: OpenAPI first. Add the paths (spec API table) under the existing Accounting tag, each security: [bearerAuth: []], description ending “Requires .”, responses 200/201, 400 (missing If-Match), 401, 403, 404, 409 (revision_mismatch, version_not_draft, draft_exists, duplicate_name), 422. Headers: ETag on every BudgetVersionView response; If-Match parameter (required) on the four writes.

BudgetRow:
type: object
required: [account_id, analytic_account_id, amounts]
additionalProperties: false
properties:
account_id: { type: string, format: uuid }
analytic_account_id: { type: string, format: uuid, nullable: true }
amounts:
type: array
minItems: 12
maxItems: 12
items: { $ref: '#/components/schemas/Money' }
description: By fiscal period seq 1..12. Each row sent replaces its 12 cells.
BudgetVersion:
type: object
required: [id, budget_id, version_no, status, revision, based_on_id, created_by, created_at,
approved_by, approved_at, discarded_by, discarded_at]
properties:
status: { type: string, enum: [draft, approved, discarded] }
# … the rest as Version's fields, dates as date-time, members as uuid, nullable where Go is a pointer
BudgetVersionView:
type: object
required: [budget, version, periods, rows]
properties:
budget: { $ref: '#/components/schemas/Budget' }
version: { $ref: '#/components/schemas/BudgetVersion' }
periods: { type: array, items: { $ref: '#/components/schemas/BudgetPeriod' } }
rows:
type: array
items:
allOf:
- $ref: '#/components/schemas/BudgetRow'
- type: object
required: [account_code, account_label, analytic_code, analytic_name, total]
properties:
account_code: { type: string }
account_label: { type: string }
analytic_code: { type: string, nullable: true }
analytic_name: { type: string, nullable: true }
total: { $ref: '#/components/schemas/Money' }
BudgetImport:
type: object
required: [csv]
additionalProperties: false
properties:
csv: { type: string, maxLength: 1048576, description: 'Header account_code,analytic_code,P01..P12.' }

Budget carries id, fiscal_year_id, fiscal_year_name, name, axis_id, axis_name, created_by, created_at, current_version_id, draft_version_id, versions: BudgetVersion[]. Run make generate.

  • Step 2: Permissions and grants. permissions.go:
PermissionBudgetManage = "accounting.budget.manage"
PermissionBudgetApprove = "accounting.budget.approve"
…
member.RegisterPermission(PermissionBudgetManage,
"Create budgets, enter and import draft amounts, discard drafts and start revisions")
member.RegisterPermission(PermissionBudgetApprove, "Approve a draft budget version, which freezes it")
// Spec "Permissions": Finance prepares budgets; approving stays with the Owner by default.
provisioning.RegisterRolePermissions("Finance", PermissionBudgetManage)

(The five-permission comment above becomes seven.) constants.yaml permissions: gains both keys; make generate.

db/migrations/00371_budget_grants.sql — the 00305 shape: Owners get both keys, system Finance roles get accounting.budget.manage, perm_version bumps; Down deletes accounting.budget.% from both and bumps.

  • Step 3: Handler tests first (budget_handlers_test.go, on newEnv): add to env a budgeter (ledger.read + budget.manage) and an approverM (ledger.read + budget.approve) with tokens, in newEnv. Tests:

  • TestBudgetRoutesArePermissioned — presence: the budgeter creates (201) and the approver approves (200); absence: the reader’s create is 403; the budgeter’s approve is 403; the approver’s PUT rows is 403.

  • TestABudgetRoundTrip — create → GET version (ETag "1") → PUT rows with If-Match: "1" → 200, ETag "2", row total "1000.0000" → PUT again with "1" → 409 code=revision_mismatch → approve with "2" → 200 → PUT → 409 code=version_not_draft → POST revisions → 201 v2 with the same rows.

  • TestPutRowsWithoutIfMatchIs400 (presence: with it, above).

  • TestRowRefusalsAre422WithTheirRule — account_not_budgetable on Bank; negative_amount naming rows[0].amounts[2]; amounts with 11 entries → 422 (decodeStrict/OpenAPI shape, rule period_count).

  • TestImportErrorsListLines — a CSV with two bad rows → 422, body errors has both lines; presence: a good file → 200.

  • TestBudgetsAreOrgIsolated — a budget of e.acc.Another(t, "Other") is 404 by id (presence: 200 in its own org via a token minted for it).

  • Step 4: Handlers. budget_handlers.go follows chart_handlers.go. The rows DTO decodes amounts as []money.Amount and checks len == 12 itself (422 period_count): decoding into a Go [12] array would silently zero-fill a short list. Then decodeStrict, parseID, mustActor-style actor read, db.InTx for writes and db.InReadOnlyTx for reads, setETag(c, view.Version.Revision) on every version response, ifMatch(c) (copied per R9) on the four writes. Row labels via the module’s existing labels(ctx, tx, "account", ids) helper. routes.go:

budgetManage, budgetApprove := PermissionBudgetManage, PermissionBudgetApprove
httpx.Protected(g, p, "GET", "/accounting/budgets", read, h.listBudgets)
httpx.Protected(g, p, "POST", "/accounting/budgets", budgetManage, h.createBudget)
httpx.Protected(g, p, "GET", "/accounting/budgets/:id", read, h.getBudget)
httpx.Protected(g, p, "POST", "/accounting/budgets/:id/revisions", budgetManage, h.reviseBudget)
httpx.Protected(g, p, "GET", "/accounting/budget-versions/:id", read, h.getBudgetVersion)
httpx.Protected(g, p, "PUT", "/accounting/budget-versions/:id/rows", budgetManage, h.putBudgetRows)
httpx.Protected(g, p, "POST", "/accounting/budget-versions/:id/import", budgetManage, h.importBudgetCSV)
httpx.Protected(g, p, "POST", "/accounting/budget-versions/:id/approve", budgetApprove, h.approveBudgetVersion)
httpx.Protected(g, p, "POST", "/accounting/budget-versions/:id/discard", budgetManage, h.discardBudgetVersion)

errors.go gains mapBudgetErr: ErrNotFound 404; ErrVersionNotDraft 409 version_not_draft; ErrDraftExists 409 draft_exists; ErrDuplicateName 409 duplicate_name; *RevisionMismatchError 409 revision_mismatch (the payables sentence); *FieldError 422 field=… rule=…: … with code; *CSVError 422 code=csv_row with .With("errors", problems); else mapErr. routes_test.go pins the nine routes’ permissions.

  • Step 5: Run go test ./internal/modules/accounting/... ./internal/app/... -count=1 → PASS (the app tests run the OpenAPI/route drift and public-route guards). Sabotage: register PUT …/rows under read instead of budgetManage → routes_test.go and TestBudgetRoutesArePermissioned red; restore; record.

  • Step 6: Commit. make generate && git status clean except intended files; "Budgets Task 6: permissions, grants and budget routes".


Files:

  • Create: internal/modules/reports/budget_handlers.go, budget_handlers_test.go
  • Modify: internal/modules/reports/{routes.go,errors.go,export_handlers.go,routes_test.go,permissions.go}
  • Modify: internal/platform/reporting/export/{export.go,params.go,project.go} and their tests (params_test.go, export_test.go, a CSV golden in the existing golden style)
  • Create: db/migrations/00372_report_export_budget_vs_actual.sql
  • Modify: api/openapi.yaml; regenerate

Interfaces:

  • Consumes: budget.VsActual, budget.CheckVsActual, the module’s accountLabels, toAccount, uuidParam, dateParam, allowQuery.

  • Produces: GET /reports/budget-vs-actual; export.ReportBudgetVsActual = "budget_vs_actual"; Params.BudgetVersionID *uuid.UUID json:"budget_version_id,omitempty".

  • Step 1: OpenAPI. Path /reports/budget-vs-actual (getBudgetVsActual, query budget_version_id uuid required, from, to date required; 200 BudgetVsActualReport; 401/403/404/422). Schema:

BudgetFigures:
type: object
required: [budget, actual, variance, percent_used]
properties:
budget: { $ref: '#/components/schemas/Money' }
actual: { $ref: '#/components/schemas/Money' }
variance: { $ref: '#/components/schemas/Money', description: 'Favourable-positive: expense budget − actual; income actual − budget.' }
percent_used: { type: string, nullable: true, pattern: '^-?\d+\.\d$', description: 'actual ÷ budget × 100, one decimal; null when the budget is zero.' }
BudgetVsActualReport:
type: object
required: [budget, version, from, to, currency, provisional, axis_name, rows, income, expense, net]
properties:
budget: { type: object, required: [id, name], properties: { id: { type: string, format: uuid }, name: { type: string } } }
version: { $ref: '#/components/schemas/BudgetVersion' }
from: { type: string, format: date }
to: { type: string, format: date }
currency: { type: string }
provisional: { type: boolean }
axis_name: { type: string, nullable: true }
rows:
type: array
items:
allOf:
- $ref: '#/components/schemas/BudgetFigures'
- type: object
required: [section, account, analytic_account]
properties:
section: { type: string, enum: [income, expense] }
account: { $ref: '#/components/schemas/ReportAccount' } # the existing accountDTO schema name — confirm
analytic_account:
type: object
nullable: true
required: [id, code, name]
properties: { id: { type: string, format: uuid }, code: { type: string }, name: { type: string } }
income: { $ref: '#/components/schemas/BudgetFigures' }
expense: { $ref: '#/components/schemas/BudgetFigures' }
net: { $ref: '#/components/schemas/BudgetFigures' }

ReportExportRequest.report and ReportExport.report enums gain budget_vs_actual; ReportExportParams gains budget_version_id (uuid) with the description “budget_vs_actual takes budget_version_id, from and to”. make generate.

  • Step 2: Export tests first. params_test.go: budget_vs_actual with the three fields validates; missing budget_version_id → InvalidParamsError{Field: "budget_version_id"}; with account_id → “not taken by budget_vs_actual”; budget_version_id on trial_balance → “not taken by trial_balance”. export_test.go: Create with a misaligned from → *InvalidParamsError{Field: "from"} (mapped from budget.FieldError), nothing inserted; presence: aligned → queued. A CSV golden for a two-row budget (header block includes Budget, Version, Rows; columns per R17; the total rows).

  • Step 3: Export implementation.

    • export.go: the constant; Filename unchanged (from/to). In Create, after CheckScope: if req.Report == ReportBudgetVsActual { if err := budget.CheckVsActual(ctx, tx, budget.VsActualParams{VersionID: *req.Params.BudgetVersionID, Range: req.Params.Range()}); err != nil { return Export{}, asInvalidParams(err) } } where asInvalidParams turns budget.ErrNotFound into {Field: "budget_version_id", Reason: "names no budget version of this organization"} and a *budget.FieldError into {Field: fe.Field, Reason: fe.Detail}.
    • params.go: allowed[ReportBudgetVsActual] = {"from", "to", "budget_version_id"}; present() and the field loop gain budget_version_id; Validate requires it for that report (and from/to as today).
    • project.go: reportTitles gains “Budget vs actual”; meta gains the R17 fields when x.Report == ReportBudgetVsActual (it keeps From/To); projectBudgetVsActual writes columns Section, Code, Account, [Analytic when AxisName != ""], Budget, Actual, Variance, % used, one row per VsActualRow, then bold Total income, Total expense, Net rows; render.Text(*PercentUsed) or blank. project() switch gains the case.
    • 00372_report_export_budget_vs_actual.sql per R18 (on this branch today: ('trial_balance','profit_and_loss','balance_sheet','general_ledger','ap_aging','budget_vs_actual'); Down without budget_vs_actual).
  • Step 4: Handler tests first (budget_handlers_test.go in reports, on the module’s env_test.go world plus a budget built through budget.Create/SaveRows in e.Tx): 200 with the Task 5 figures as strings ("-200.0000", "120.0"); percent_used JSON null for a zero budget; 422 for misaligned from naming period_alignment; 404 for an unknown budget_version_id; 403 without reports.read (presence: 200 with it); an unknown query parameter → 422 (allowQuery).

  • Step 5: Handler. getBudgetVsActual: allowQuery(c, "budget vs actual", "budget_version_id", "from", "to"), parse, InReadOnlyTx → budget.VsActual → labels via accountLabels → DTO. routes.go: httpx.Protected(g, p, "GET", "/reports/budget-vs-actual", read, h.getBudgetVsActual). errors.go: budget.ErrNotFound → 404 “no such budget version in this organization”; *budget.FieldError → 422 with its rule; the ErrUnknownReport sentence lists budget_vs_actual. export_handlers.go’s request DTO gains budget_version_id. permissions.go’s reports.read description gains “, and budget vs actual”. routes_test.go pins the new route.

  • Step 6: Run go test ./internal/platform/reporting/... ./internal/modules/reports/... ./internal/app/... ./internal/kernel/db/... -count=1 → PASS. Sabotage: drop budget_vs_actual from 00372’s list → the export Create test fails on report_export_report_known; restore; record.

  • Step 7: Commit. "Budgets Task 7: budget-vs-actual report and export".


Files:

  • Create: web/src/features/accounting/budgets/{api.ts,BudgetsPage.tsx,NewBudgetDialog.tsx,BudgetPage.tsx,BudgetGrid.tsx,grid.ts,csv.ts,routes.tsx}
  • Create: web/src/features/accounting/budgets/__tests__/{grid.test.ts,csv.test.ts,BudgetPage.test.tsx}
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/budgets.json
  • Modify: web/src/app/routes.ts, web/src/app/shell/nav.ts, the i18n namespace list (where payables is registered — grep 'payables' in web/src/lib/i18n)

Interfaces:

  • Consumes: generated schema.d.ts from Task 6 (/accounting/budgets…), useAccounts/accountOptions (features/accounting/queries.ts), useAnalyticAxes (features/accounting/entries/api.ts), DataTable, Dialog, Combobox, Select, Input, Button, Tag, PageHeader, useCan, formatMoney, isMoneyInput, normalizeMoneyInput, addMoney, sumMoney, compareMoney, formatDate, pickLabel.

  • Produces: routes /accounting/budgets and /accounting/budgets/$budgetId (search version?: uuid); nav item.

  • Step 1: Pure helpers, tests first.

grid.ts:

export type RowKey = string; // `${account_id}|${analytic_account_id ?? ''}`
export interface GridRow {
key: RowKey;
account_id: string;
analytic_account_id: string | null;
amounts: string[]; // 12 money strings as typed
}
export const rowKey = (a: string, aa: string | null): RowKey => `${a}|${aa ?? ''}`;
/** Rows whose amounts differ (by value, not text) from the server's. */
export function changedRows(server: GridRow[], edited: GridRow[]): GridRow[];
/** The PUT body: each changed row with blanks as "0.00" and every amount normalized. */
export function toPutBody(rows: GridRow[]): { rows: BudgetRow[] };
/** A cell is valid when empty or a non-negative money input. */
export function cellError(s: string): 'invalid' | 'negative' | undefined;
export function rowTotal(amounts: string[]): string; // sumMoney, blanks as zero

Tests (grid.test.ts): a row edited "1000" → "1000.00" against a server "1000.0000" is unchanged (compare with compareMoney); a new row is changed; blanks become "0.00" in the body; "-1" → negative; "1,000" → invalid (the grid wants plain decimals; CSV is where grouping is accepted); totals.

csv.ts:

/** The import format (spec B10): header, then one line per row, codes not ids. */
export function toCSV(rows: { account_code: string; analytic_code: string | null; amounts: string[] }[]): string;

Test (csv.test.ts): header is exactly account_code,analytic_code,P01,…,P12; a code containing a comma is quoted; amounts are written as the server sent them trimmed to 2 dp when the last two are zero ("1000.0000" → "1000.00"); \r\n line endings (Excel).

  • Step 2: api.ts (the payables/api.ts shape): budgetKeys, useBudgets(fiscalYearId?), useBudget(id), useBudgetVersion(id) (keeps etag from the response header — read response.headers.get('ETag') as payables does), useCreateBudget, useSaveRows(versionId), useImportCSV(versionId), useApproveVersion, useDiscardVersion, useReviseBudget — every write sends If-Match from the cached view and, on success, setQueryData for the version and invalidates the budget.

  • Step 3: Pages.

    • BudgetsPage: PageHeader (title, “New budget” when useCan('accounting.budget.manage')); fiscal-year filter (Select from /accounting/fiscal-years); DataTable tableId="budgets" columns: name (always shown), fiscal year, axis, current version (Original / Revision n + Approved tag), draft (tag or —). Row click → /accounting/budgets/$id.
    • NewBudgetDialog: name, fiscal year (default: the year containing today), axis (optional; only axes whose applies_to includes income or expense). On success navigate to the budget.
    • BudgetPage: header with the budget name and fiscal year; version Select (default: the draft if any, else the current); status tag; actions (each only when permitted and meaningful): Save (disabled until dirty and valid), Import CSV (a hidden <input type="file" accept=".csv,text/csv">, await file.text(), POST; 422 errors shown as a list in a dialog), Download CSV (toCSV → Blob → the existing download.ts helper in features/reports — reuse it), Approve (ConfirmDialog: “Approving freezes this version. Changes after that need a new revision.”), Discard (ConfirmDialog), New revision (when there is no draft), View against actual (link to /reports?report=budget_vs_actual&budget_version=<id>&from=<P1 start>&to=<P12 end>). A stale save (409 revision_mismatch) shows “Someone else changed this budget. Reload to see their changes.” with a Reload button (refetch; drop local edits after confirm).
    • BudgetGrid: a plain <table> (not DataTable: editable cells), sticky first columns (code + name, and analytic code when the budget has an axis), 12 period columns headed by month (formatDate(period.starts_on, { month: 'short', year: '2-digit' })), a Total column; Input cells inputMode="decimal", right-aligned, aria-label="<account code> P<seq, two digits>" (e.g. 6100 P03; stable across locales, which a month name is not); invalid cells get aria-invalid and the error text in a Tooltip; read-only (text, formatMoney) when the version is not a draft or the user lacks manage. Add row: account Combobox over postable income/expense accounts (from useAccounts), and analytic Combobox over the axis’s accounts when the budget has one; a duplicate key is refused inline. Below 768 px the grid scrolls horizontally inside its card (the page itself does not).
  • Step 4: Component test first-then-green (BudgetPage.test.tsx, MSW): renders rows and totals; editing a cell enables Save and the PUT body contains only that row with If-Match: "3"; a 409 revision_mismatch shows the reload message; an approved version renders no inputs and no Save (presence: the draft renders inputs); Import with a 422 lists both line errors.

  • Step 5: Routes and nav. routes.tsx: two routes under appRoute (the approvers/routes.tsx shape), RequirePermission permission="accounting.ledger.read"; spread ...budgetsRoutes in web/src/app/routes.ts beside approversRoutes. nav.ts (APPEND-ONLY): after the approvers item,

{
group: 'accounting',
to: '/accounting/budgets',
labelKey: 'budgets:nav',
icon: Calculator, // add to the lucide import list
permission: 'accounting.ledger.read',
},
  • Step 6: Strings. budgets.json — en / ms / zh (keys identical):
key en ms zh
nav Budgets Belanjawan 预算
page.title Budgets Belanjawan 预算
page.subtitle Plan income and spending by account and period Rancang pendapatan dan perbelanjaan mengikut akaun dan tempoh 按科目和期间规划收入与支出
new New budget Belanjawan baharu 新建预算
field.name Name Nama 名称
field.fiscalYear Fiscal year Tahun kewangan 财政年度
field.axis Split by Pecahan mengikut 拆分维度
field.axisNone No split Tiada pecahan 不拆分
col.version Current version Versi semasa 当前版本
col.draft Draft Draf 草稿
version.original Original Asal 原始版本
version.revision Revision {{n}} Semakan {{n}} 修订 {{n}}
status.draft Draft Draf 草稿
status.approved Approved Diluluskan 已批准
status.discarded Discarded Dibuang 已作废
action.save Save Simpan 保存
action.import Import CSV Import CSV 导入 CSV
action.download Download CSV Muat turun CSV 下载 CSV
action.approve Approve Luluskan 批准
action.discard Discard draft Buang draf 作废草稿
action.revise New revision Semakan baharu 新建修订
action.addRow Add row Tambah baris 添加行
action.viewActual Compare with actual Banding dengan sebenar 与实际对比
grid.account Account Akaun 科目
grid.analytic {{axis}} {{axis}} {{axis}}
grid.total Total Jumlah 合计
grid.duplicate This row is already in the budget Baris ini sudah ada dalam belanjawan 该行已在预算中
cell.invalid Enter an amount like 1200.50 Masukkan amaun seperti 1200.50 请输入如 1200.50 的金额
cell.negative A budget amount cannot be negative Amaun belanjawan tidak boleh negatif 预算金额不能为负数
approve.title Approve this version? Luluskan versi ini? 批准此版本?
approve.body Approving freezes this version. Changes after that need a new revision. Kelulusan membekukan versi ini. Sebarang perubahan selepas itu memerlukan semakan baharu. 批准后此版本将被冻结,之后的更改需要新建修订。
discard.title Discard this draft? Buang draf ini? 作废此草稿?
discard.body The draft is kept for the record but can no longer be edited or approved. Draf ini disimpan sebagai rekod tetapi tidak boleh disunting atau diluluskan lagi. 草稿将保留作记录,但不能再编辑或批准。
stale Someone else changed this budget. Reload to see their changes. Orang lain telah mengubah belanjawan ini. Muat semula untuk melihat perubahan mereka. 其他人已修改此预算。请重新加载以查看其更改。
reload Reload Muat semula 重新加载
import.errors The file was not imported. Fix these rows and try again: Fail tidak diimport. Betulkan baris ini dan cuba lagi: 文件未导入。请修正以下行后重试:
import.line Row {{line}}, {{column}}: {{reason}} Baris {{line}}, {{column}}: {{reason}} 第 {{line}} 行,{{column}}:{{reason}}
empty No budgets for this year yet Belum ada belanjawan untuk tahun ini 本年度尚无预算
emptyRows Add a row, or import a CSV, to start Tambah baris, atau import CSV, untuk bermula 添加一行或导入 CSV 以开始

(The server’s CSV reason sentences are English — record text for an administrator, like every 422 detail; the web shows them inside the translated frame.)

  • Step 7: Run make web (lint, typecheck, vitest incl. nofloat and i18nparity) → PASS. Sabotage: delete one ms key → i18nparity.test.ts red; restore; record.

  • Step 8: Commit. "Budgets Task 8: web budgets list, grid and CSV".


Task 9: Web — the Budget vs actual report tab

Section titled “Task 9: Web — the Budget vs actual report tab”

Files:

  • Create: web/src/features/reports/BudgetVsActualView.tsx, BudgetVsActualView.test.tsx
  • Modify: web/src/features/reports/{ReportsPage.tsx,routes.tsx,api.ts}, web/src/lib/i18n/locales/{en,ms,zh}/reports.json

Interfaces:

  • Consumes: GET /reports/budget-vs-actual, useBudgets/useBudget (Task 8’s api.ts), ExportMenu, ReportFlags (provisional).

  • Produces: ReportsSearch.budget_version?: string; export params {budget_version_id, from, to}.

  • Step 1: Search and page. routes.tsx: the report enum gains budget_vs_actual; budget_version: z.string().uuid().optional().catch(undefined). ReportsPage.tsx: the tab list gains it; for this tab the filter row is Budget (Select of budgets, newest year first) → Version (Select, default current approved else draft) → From period / To period (Selects over the budget year’s periods: option value = starts_on for From and ends_on for To; defaults P1 → the period containing today, clamped to the year); exportParams for this report is { budget_version_id, from, to } and the export menu is disabled until a version is chosen.

  • Step 2: View test first (MSW): renders income and expense sections with totals and the net row; a negative variance has the text-danger class and a − sign (via formatMoney), a positive one text-success; percent_used: null renders —; a draft version shows the “Draft budget — not yet approved” flag (presence: an approved one does not); an axis budget shows the analytic column (absence: a no-axis one does not).

  • Step 3: BudgetVsActualView.tsx — StatementTable-like layout (reuse StatementTable.tsx if its row shape fits; if not, a plain table matching its classes). Columns: Code, Account, [axis name], Budget, Actual, Variance, % used.

  • Step 4: Strings (reports.json, all three):

key en ms zh
page.tabs.budget_vs_actual Budget vs actual Belanjawan lwn sebenar 预算与实际
bva.budget Budget Belanjawan 预算
bva.version Version Versi 版本
bva.fromPeriod From period Dari tempoh 起始期间
bva.toPeriod To period Hingga tempoh 截止期间
bva.actual Actual Sebenar 实际
bva.variance Variance Varians 差异
bva.percentUsed % used % digunakan 已使用 %
bva.income Income Pendapatan 收入
bva.expense Expenses Perbelanjaan 支出
bva.totalIncome Total income Jumlah pendapatan 收入合计
bva.totalExpense Total expenses Jumlah perbelanjaan 支出合计
bva.net Net Bersih 净额
bva.draft Draft budget — not yet approved Belanjawan draf — belum diluluskan 草稿预算——尚未批准
bva.discarded Discarded budget version Versi belanjawan yang dibuang 已作废的预算版本
bva.pickBudget Choose a budget to compare Pilih belanjawan untuk dibandingkan 请选择要对比的预算
bva.axisNote Actuals are each {{axis}}’s share of postings; spend not tagged to a budgeted {{axis}} is not shown. Jumlah sebenar ialah bahagian setiap {{axis}} daripada catatan; perbelanjaan yang tidak ditandakan kepada {{axis}} yang dibelanjakan tidak ditunjukkan. 实际数为各{{axis}}在过账中的分摊额;未标记到已编预算{{axis}}的支出不显示。

(Check the ms of bva.axisNote reads naturally when implementing; “yang dibelanjakan” must mean “budgeted” — use “yang mempunyai belanjawan”: “…perbelanjaan yang tidak ditandakan kepada {{axis}} yang mempunyai belanjawan tidak ditunjukkan.”)

  • Step 5: Run make web → PASS. Commit "Budgets Task 9: budget vs actual report tab".

Task 10: End to end on lane 5, roadmap, deploy notes, full suite

Section titled “Task 10: End to end on lane 5, roadmap, deploy notes, full suite”

Files:

  • Modify: scripts/dev-stack.sh, web/playwright.config.ts, web/vite.config.ts (R19, unless already parameterized on the integration branch)

  • Modify: web/e2e/support.ts (ensureFinanceBudgets), web/e2e/seed.ts (Finance gains accounting.budget.manage)

  • Create: web/e2e/budgets.spec.ts

  • Modify: docs/ROADMAP.md, docs/DEPLOY_NOTES.md

  • Step 1: Ports (R19). dev-stack.sh: PG_PORT=${PG_PORT:-55432}, MINIO_PORT=${MINIO_PORT:-59000}, API_PORT=${API_PORT:-3000}, STACK=${STACK_NAME:-dev}; containers erp-$STACK-pg/erp-$STACK-minio; state dir .dev-stack${STACK_NAME:+-$STACK_NAME}; PORT=$API_PORT and every localhost:3000 → localhost:$API_PORT. playwright.config.ts: const API = process.env.API_PORT ?? '3000'; const WEB = process.env.WEB_PORT ?? '5173'; used in baseURL, the health URL, the vite command (--port ${WEB}) and a STACK_NAME/port env passed to the stack command. vite.config.ts: proxy target http://localhost:${process.env.API_PORT ?? '3000'}. support.ts already reads API_BASE. Defaults unchanged, so the user’s dev stack behaves exactly as before.

Lane 5 run command (record in the commit body):

Terminal window
STACK_NAME=lane5 PG_PORT=56462 MINIO_PORT=59150 API_PORT=3150 WEB_PORT=5196 \
API_BASE=http://localhost:3150/v1 pnpm -C web exec playwright test e2e/budgets.spec.ts
  • Step 2: The e2e. support.ts:
/** Arrange, not under test: a dev org seeded before budgets has a Finance role without the key. */
export async function ensureFinanceBudgets() {
const owner = await api('owner');
const { data: roles } = await owner.GET('/org/roles');
const fin = roles?.roles.find((r) => r.name === 'Finance');
if (!fin) throw new Error('Finance role not seeded');
const keys = new Set(fin.permissions);
for (const k of ['accounting.budget.manage', 'accounting.ledger.read', 'reports.read']) keys.add(k);
await owner.PUT('/org/roles/{id}/permissions', { params: { path: { id: fin.id } }, body: { permissions: [...keys] } });
}

budgets.spec.ts:

import { expect, test } from '@playwright/test';
import { api, as, ensureFinanceBudgets, runTag } from './support';
test.beforeAll(async () => {
await ensureFinanceBudgets();
});
test('budget → grid → approve → actual posts → budget vs actual shows it', async ({ browser }) => {
const tag = runTag();
const code = `9${tag.replace(/\D/g, '').slice(-6)}`;
const owner = await api('owner');
// Arrange: a fresh expense account, so no other flow's postings reach this row.
const { data: accounts } = await owner.GET('/accounting/accounts');
const expRoot = accounts!.accounts.find((a) => a.type === 'expense' && !a.postable && !a.parent_id)!;
await owner.POST('/accounting/accounts', {
body: { code, name: `E2E budget ${tag}`, type: 'expense', parent_id: expRoot.id },
});
// 1. Finance creates the budget and enters this month's amount.
const fin = await as(browser, 'finance');
await fin.goto('/accounting/budgets');
await fin.getByRole('button', { name: 'New budget' }).first().click();
const dlg = fin.getByRole('dialog');
await dlg.getByLabel('Name').fill(`E2E ${tag}`);
await dlg.getByRole('button', { name: 'Save' }).click();
await fin.getByRole('button', { name: 'Add row' }).click();
await fin.getByRole('combobox', { name: 'Account' }).click();
await fin.getByRole('option', { name: new RegExp(code) }).click();
// Today's period: from the fiscal-year API (confirm the response carries periods; else
// GET the new budget's version, whose `periods` do).
const today = new Date().toISOString().slice(0, 10);
const { data: years } = await owner.GET('/accounting/fiscal-years');
const period = years!.years.flatMap((y) => y.periods).find((p) => p.starts_on <= today && today <= p.ends_on)!;
await fin.getByLabel(`${code} P${String(period.seq).padStart(2, '0')}`).fill('1000');
await fin.getByRole('button', { name: 'Save' }).click();
await expect(fin.getByRole('row', { name: new RegExp(code) })).toContainText('1,000.00');
// Finance cannot approve (approve is Owner-only by default).
await expect(fin.getByRole('button', { name: 'Approve' })).toHaveCount(0);
// 2. The owner approves.
const own = await as(browser, 'owner');
await own.goto('/accounting/budgets');
await own.getByRole('row', { name: new RegExp(`E2E ${tag}`) }).click();
await own.getByRole('button', { name: 'Approve' }).click();
await own.getByRole('dialog').getByRole('button', { name: 'Approve' }).click();
await expect(own.getByText('Approved').first()).toBeVisible();
// PRESENCE of the row, ABSENCE of spend: budget 1,000.00, actual 0.00, 0.0 % used.
await own.getByRole('link', { name: 'Compare with actual' }).click();
const row = own.getByRole('row', { name: new RegExp(code) });
await expect(row).toContainText('1,000.00');
await expect(row).toContainText('0.0');
// 3. An actual posts to the account today (a manual journal, the simplest ledger path).
// Confirm the POST /accounting/entries body shape in schema.d.ts (ManualEntryInput).
const bank = accounts!.accounts.find((a) => a.control_kind === 'bank' && a.postable)!;
const newAcct = (await owner.GET('/accounting/accounts')).data!.accounts.find((a) => a.code === code)!;
await owner.POST('/accounting/entries', {
body: {
entry_date: new Date().toISOString().slice(0, 10),
description: `E2E budget ${tag}`,
lines: [
{ account_id: newAcct.id, debit: '250.00', credit: '0.00' },
{ account_id: bank.id, debit: '0.00', credit: '250.00' },
],
},
});
// 4. The report shows it: actual 250.00, variance 750.00, 25.0 % used.
await own.reload();
await expect(row).toContainText('250.00');
await expect(row).toContainText('750.00');
await expect(row).toContainText('25.0');
});

(The report link’s default range is P1 → the period containing today, so today’s entry is in range. If the dev org’s bank account refuses manual lines — a module-owned account, wave-3 §1.5 — use the misc journal’s default account or any postable asset without an owner module; pick it by owner_module === null.)

Run on lane 5 (Step 1’s command) → first run FAILS if the stack predates this branch’s migrations (Finance lacks the key; beforeAll fixes it) → PASS.

  • Step 3: Roadmap. Replace the Phase 2 line with:
- [x] Budgets per account / period / axis, budget-vs-actual
Design: [`2026-09-27-budgets-design.md`](superpowers/specs/2026-09-27-budgets-design.md) (B1–B14), migrations `00370`–`00372`
Plan: [`2026-09-27-budgets.md`](superpowers/plans/2026-09-27-budgets.md) (10 tasks, Rulings R1–R21)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — migrations only; existing Owners gain `accounting.budget.*`, Finance `accounting.budget.manage`
- Versions: original then revisions; approval freezes (a lock under `accounting.budget.approve`, not a workflow kind)
- Actuals are `reporting.Actuals` — the P&L's figures, pinned by a test
- End to end: budget → grid → approve → post → budget vs actual (`web/e2e/budgets.spec.ts`)
- [ ] Open decisions (owner's call): preparer may approve; budgets kept forever; actual by posting date
- Deferred: spend checks against budget at claim/invoice time; balance-sheet and multi-axis budgets;
unbudgeted spend per analytic account on an axis budget; original-vs-revised in one report
  • Step 4: Deploy notes. A new top section “Phase 2 budgets”: migrations 00370 (three tables, never deleted), 00371 (existing Owners gain accounting.budget.manage and accounting.budget.approve; existing Finance roles gain accounting.budget.manage; perm_version bumps so live sessions refresh once), 00372 (report_export accepts budget_vs_actual). No provisioning step, no configuration, no new environment variable.

  • Step 5: Full suite, once. make generate && git diff --exit-code, then go test -p 2 -count=1 ./... and make web. Any failure outside budget/reporting/reports/accounting is investigated, not skipped.

  • Step 6: Commit. "Budgets Task 10: e2e on lane 5, roadmap, deploy notes".


Spec Task
B1 package beside the chart, routes on accounting 3, 6
B2 actuals from reporting.Actuals; P&L equality test 2, 5
B3 fiscal-year periods, FK through fiscal_period 1
B4 one axis; applies_to; overlap 1 (trigger), 3 (Go)
B5 income/expense, postable, ≥ 0 1 (CHECK), 3
B6 base currency 2, 5
B7 versions, one draft, frozen 1, 3
B8 approve permission; lock not workflow 3, 6
B9 grid, set by row, If-Match 3, 6, 8
B10 CSV 4, 8 (download)
B11 report rows, variance, % used, totals, alignment 5, 7, 9
B12 export 7
B13 never deleted, audited 1, 3, 4
B14 names 3, 8
Permissions and grants 6
e2e 10

No “TBD”. Three “confirm in schema.d.ts” notes (the report-account schema name in Task 7, the manual entry body and bank account choice in Task 10) name exactly what to check and what to do if it differs.

budget.Row.Amounts [12]money.Amount ↔ wire amounts (12 Money) ↔ GridRow.amounts: string[]. VersionView.Version.Revision ↔ ETag. export.ReportBudgetVsActual = "budget_vs_actual" = the report_export_report_known entry = the web tab value. Params.BudgetVersionID ↔ budget_version_id ↔ web search budget_version. Permission keys match constants.yaml, permissions.go, 00371 and seed.ts.