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)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00370–00379only. This plan uses00370–00372. Task 1 registers{370, 379, "Phase 2 budgets"}ininternal/kernel/db/migrationranges_test.goin the same commit as00370. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (the OpenAPIMoneyschema). Compare withCmp, never==. Percentages are strings frommoney.PercentOf. Nofloat64ininternal/platform/accounting/budget, the new handlers, orweb/src/features/accounting/budgets(web/src/drift/nofloat.test.tsscans the web;percentOfinweb/src/lib/money.tsreturns 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),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid, aCONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite(org_id, x)foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints. Every
CHECK/UNIQUE/FOREIGN KEYisCONSTRAINT <table>_<rule>; no name ends in_check,_keyor_excl(TestEveryConstraintIsNamed). A trigger’s refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name. - Pinned functions. Every trigger function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (public.budget_version) (TestEveryFunctionPinsItsSearchPath). NoSECURITY DEFINER. - Every SQL statement names
org_id = $nfor each org-scoped table it touches, join equalities included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.OrgFromContext; nothing takes an org argument. - Retention.
budgetisappendOnly(REVOKE UPDATE, DELETE: never changed after creation);budget_versionandbudget_lineareamendable(REVOKE DELETE) ininternal/kernel/db/retention_test.go(spec B13). - Not the ledger. Nothing in this plan imports
accounting/ledgeroutside tests, writesjournal_*, or callsaccounting.RegisterPostingRule.reportinggains read-only code only (TestReportingNeverWriteskeeps passing). - Lock order:
budget_versionrowFOR UPDATE(revision check) → cell writes →audit.Write, last. Revise takes no lock:budgethas 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 indb.InReadOnlyTx. - Business date is
clock.TodayUTC(c); timestamps arec.Now().UTC(); nevertime.Now()in code under test. EveryDATEin 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 detailfield=<name> rule=<code>: <sentence>and.With("code", <code>). - Every route goes through
httpx.Protectedwith its permission and is documented inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces);routes_test.goof the module pins each route’s permission.api/public_routes.txtgains nothing.make generateafter every migration, OpenAPI orconstants.yamlchange; generated files are committed andgit statusis clean after it. - Vocabulary values are strings: version status
draft | approved | discarded; reportbudget_vs_actual; sectionincome | expense. - i18n. Every UI string is a key in
web/src/lib/i18n/locales/{en,ms,zh}/budgets.jsonor the existingreports.json/shell.json;web/src/drift/i18nparity.test.tsrequires 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(throughaccountingtest.New) per test; run only the touched packages while iterating (go test ./internal/platform/accounting/budget/... -count=1); one fullgo test -p 2 -count=1 ./...in Task 10 only. - e2e lane 5: Postgres
56462, MinIO59150, api3150, vite5196. 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>
Rulings
Section titled “Rulings”Places the spec is silent or leaves a choice to the plan. Format: Ruling: <decision> — <why>.
- R1 Ruling: Package
internal/platform/accounting/budget(package namebudget); test world helpers live in its own_test.gofiles on top ofaccountingtest.Envandreportingtest.Seed— the golden books already hold income, expense and analytic shares in FY2026, which is exactly what budget-vs-actual needs. Nobudgettestpackage: nothing outside this package andmodules/accountingbuilds 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 periodseq1..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 bumpsrevision(the caller’s ETag moves on every accepted write). - R4 Ruling: The same (account, analytic account) twice in one request is 422
duplicate_row.amountsmust have exactly 12 entries (422period_count). Each amount parses withmoney.Parseand must be ≥ 0 (422negative_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_overlapnames both codes. - R6 Ruling: A budget’s
nameis 1–120 characters after trim, unique per fiscal year ignoring case (409duplicate_name). It is not record text (no statutory record carries it), so norecordtextcheck. - R7 Ruling:
axis_idon a budget must name an active axis whoseapplies_toincludesincomeorexpense(422axis_not_applicable); a row’s account type must be in the axis’sapplies_to(422axis_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 addscsv_header,csv_row(422 with anerrorsarray). - R9 Ruling: The ETag of a version is its
revision, sent asETag: "<n>"on every version response; writes requireIf-Match(400 without it). The accounting module copiesifMatch/setETagfrominternal/modules/payables/http.gointo its ownhttp.go(module-local, like itspermissionLoader). - R10 Ruling: Approving or discarding requires
If-Matchtoo, so a member cannot approve a draft whose cells changed after they last read it. - R11 Ruling:
GET /accounting/budgetswithoutfiscal_year_idlists 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 accountcodeandlabel(translated,accountLabels-style) and analyticcode/name. - R13 Ruling (B11 dates):
frommust equal a period’sstarts_onandtoa period’sends_on, both periods of the budget’s fiscal year,from ≤ to; otherwise 422period_alignment. The web offers period selects, so a user never types these. - R14 Ruling:
reporting.Actualstakes 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’saccountDTO) andanalytic_account {id, code, name} | null. - R16 Ruling:
percent_usedhas one decimal ("120.0");nullwhen 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, andRows(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
00372rebuildsreport_export_report_known. Its list is the list on the integration branch when this item merges plusbudget_vs_actual. If AR (00310–00329) or another item added a report first, include theirs; theDownrestores exactly that list.export.go/params.go/project.go/the reports module’smapErrmessage/ReportsPage.tsxtab list/routes.tsxenum are shared edit points with those items: merge, never overwrite. - R19 Ruling (e2e ports):
scripts/dev-stack.shandweb/playwright.config.tshardcode 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
SaveRowscode path (one validation and one audit shape), withaudit.Entry.Action = "import"andAftercarryingsha256,rowsandchanged.
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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). |
New Go code
Section titled “New Go code”| 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. |
Modified (shared)
Section titled “Modified (shared)”| 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)onfiscal_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(packagebudget_test), onaccountingtest.New(t)+SeedBooks, inserting throughe.Txwith 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’srevisionsucceeds; after approval,UPDATE budget_version SET revision = revision + 1failsbudget_version_frozen; so does moving a discarded version back todraft.TestOneDraftPerBudget— a second version asapprovedinserts; a seconddraftfails on indexbudget_version_one_draft(SQLSTATE 23505).TestACellFollowsItsBudgetsAxis— on a budget with axiscost_centre: a cell with(axis_id, analytic_account_id) = (cost_centre, HQ)inserts; with(project, ProjA)failsbudget_line_axis_matches_budget; with both null failsbudget_line_axis_matches_budget. On a budget with no axis: both null inserts;(cost_centre, HQ)failsbudget_line_axis_matches_budget.TestACellCannotMoveToAnotherCell— updatingamounton a draft succeeds; updatingaccount_idfailsbudget_line_cell_fixed.TestACellIsInItsBudgetsYear— a cell whosefiscal_period_idis a period of FY2027 (fiscal.CreateYear2027-01-01) while the budget is FY2026 fails with SQLSTATE 23503 onbudget_line_period_fk.TestACellIsNotNegative—0inserts;-0.01failsbudget_line_amount_nonnegative.TestACellIsUniquePerVersionAccountAnalyticPeriod— a second identical cell with null analytic failsbudget_line_cell(theNULLS 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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER budget_version_frozen BEFORE UPDATE ON budget_version FOR EACH ROW EXECUTE FUNCTION budget_version_frozen();
-- +goose StatementBeginCREATE 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 StatementEndCREATE TRIGGER budget_line_guard BEFORE INSERT OR UPDATE ON budget_line FOR EACH ROW EXECUTE FUNCTION budget_line_guard();
-- +goose DownDROP 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.gogains{370, 379, "Phase 2 budgets"}.retention_test.go:"budget"intoappendOnlywith the comment// Phase 2 budgets (B13): created once, never changed.;"budget_version","budget_line"intoamendablewith// 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) thengo 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 RLSand reading that its checked-table count rose by 3. -
Step 5: Sabotage. (a) Comment out the
v_statuscheck inbudget_line_guardin a scratch migration edit, rerunTestAnApprovedVersionRefusesCells, read the red message (the insert succeeds), restore. (b) RemoveNULLS NOT DISTINCT, rerunTestACellIsUniquePerVersionAccountAnalyticPeriod, read red, restore. (c) Remove"budget_line"fromamendable, runTestOnlyOperationalTablesGrantDeleteToTheAppRole, 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 ininternal/platform/reporting);reportingtest.Seed. - Produces:
func PercentOf(part, whole Amount) (string, error) // part × 100 / whole, 1 dp, half away from zero; ErrDivideByZero
// platform/reportingtype 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:
PercentOftest 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:
Actualstests first, onreportingtest.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 childHQ-1under HQ (analytic.CreateAccountwithParentID), post Dr Travel 50 with 100%HQ-1on 2026-03-25;ByAnalytic[{Travel, HQ}] = 1250and requestingHQ-1alone gives 50.TestActualsExcludeTheClosingEntry—Options{CloseFY2026: true}: FY2026ByAccountequals the unclosed seed’s figures (Revenue still 5000).TestActualsRefuseMixedAxes—[HQ, ProjA]→ErrAnalyticAxesMixed(presence:[HQ, Ops]succeeds, above).TestActualsReportProvisional— FY2026 open:Provisionaltrue; withCloseFY2026, a range inside FY2026: false.
Run → FAIL (undefined: reporting.Actuals).
- Step 3: Extract the descendant walk from
resolveScopeinscope.gointo
// 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, includingTestReportingNeverWrites. -
Step 6: Sabotage. Change
ELSE -d.base_amounttoELSE d.base_amount; runTestActualsByAnalyticAccountAreStoredShares→ red (Revenue HQ reads −3000). DropAND e.semantic <> 'year_end_close'; runTestActualsExcludeTheClosingEntry→ 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(packagebudget_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; anaudit_logrowentity_kind='budget' action='create'(read withe.H.Admin). -
TestNamesAreUniquePerYearIgnoringCase— presence:"Opex"in FY2026 and"Opex"in FY2027 both succeed; absence:" opex "in FY2026 →ErrDuplicateName. -
TestCreateRefusesAnAxisThatAppliesToNeitherIncomeNorExpense— create an axis withapplies_to = [asset]viaanalytic.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 withBasedOnID = v1, itsGetVersion(v2).Rowsequal v1’s exactly; v1Status=approved,ApprovedBy=by. -
TestReviseRefusesWhileADraftExists— presence: after approving v1,Revisesucceeds; absence: a secondRevise→ErrDraftExists. -
TestReviseWithNothingApprovedStartsEmpty— discard v1,Revise→ v2 draft with no rows andBasedOnID = 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);Revisionrose by 2; the second save’s audit rowAfter.changedlists 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, fieldrows[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— createHQ-1under 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.goandbudget.go. Straightforward SQL, every statement withorg_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 != StatusDraftcheck inlockVersion:TestAFrozenVersionRefusesEverythingmust still fail — now on the trigger (budget_line_version_is_draftmapped toErrVersionNotDraft), 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. RemovecheckOverlap’s call:TestAnAncestorAndItsDescendantCannotBothBeBudgetedred. Record. -
Step 8: Commit.
"Budgets Task 3: budget lifecycle and row saves".
Task 4: CSV import
Section titled “Task 4: CSV import”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; *CSVErrorfunc 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; missingP12, extra column, reordered → one problemLine 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"→ problemnegative;"abc"→ problemnot 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→ problemaccount_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 rowaction='import'hassha256of the text androws: 1. -
TestImportIsAllOrNothing— presence: a file of two good rows imports; absence: two good rows and one with unknown9999→*CSVErrorwhose problem names line 4, columnaccount_code, “no account with code 9999”; the grid is unchanged (read back). -
TestImportResolvesAnalyticCodesOnTheBudgetsAxis— axis budget on cost_centre:6100,HQ,…imports;6100,,…→ problemanalytic_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” (theFieldErrorfromvalidateRowsis 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*FieldErrorwhose field startsrows[i]becomes aCSVErrorproblem on that row’s line. -
Step 5: Run → PASS. Sabotage: make
ParseCSVskip the header check → the reordered-header case goes red; restore. Record. -
Step 6: Commit.
"Budgets Task 4: CSV import".
Task 5: Budget-vs-actual
Section titled “Task 5: Budget-vs-actual”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 inRows(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-31ok; absence (*FieldError, ruleperiod_alignment):2026-02-02..2026-03-31(fieldfrom),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 carriesVersion.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 →
TestVarianceIsFavourablePositivered; skip the unbudgeted loop →TestUnbudgetedSpendShowsOnANoAxisBudgetred; 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 schemasBudget,BudgetVersion,BudgetVersionView,BudgetRow,BudgetPeriod,BudgetCreate,BudgetRowsPut,BudgetImport,BudgetList, and the 422 problem extensionerrors: [{line, column, reason}]for import. -
Step 1: OpenAPI first. Add the paths (spec API table) under the existing
Accountingtag, eachsecurity: [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:ETagon everyBudgetVersionViewresponse;If-Matchparameter (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, onnewEnv): add toenvabudgeter(ledger.read + budget.manage) and anapproverM(ledger.read + budget.approve) with tokens, innewEnv. 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’sPUT rowsis 403. -
TestABudgetRoundTrip— create →GETversion (ETag"1") →PUT rowswithIf-Match: "1"→ 200, ETag"2", row total"1000.0000"→PUTagain with"1"→ 409code=revision_mismatch→ approve with"2"→ 200 →PUT→ 409code=version_not_draft→POST revisions→ 201 v2 with the same rows. -
TestPutRowsWithoutIfMatchIs400(presence: with it, above). -
TestRowRefusalsAre422WithTheirRule—account_not_budgetableon Bank;negative_amountnamingrows[0].amounts[2];amountswith 11 entries → 422 (decodeStrict/OpenAPI shape, ruleperiod_count). -
TestImportErrorsListLines— a CSV with two bad rows → 422, bodyerrorshas both lines; presence: a good file → 200. -
TestBudgetsAreOrgIsolated— a budget ofe.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.gofollowschart_handlers.go. The rows DTO decodesamountsas[]money.Amountand checkslen == 12itself (422period_count): decoding into a Go[12]array would silently zero-fill a short list. ThendecodeStrict,parseID,mustActor-style actor read,db.InTxfor writes anddb.InReadOnlyTxfor 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 existinglabels(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: registerPUT …/rowsunderreadinstead ofbudgetManage→routes_test.goandTestBudgetRoutesArePermissionedred; restore; record. -
Step 6: Commit.
make generate && git statusclean except intended files;"Budgets Task 6: permissions, grants and budget routes".
Task 7: The report route and its export
Section titled “Task 7: The report route and its export”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’saccountLabels,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, querybudget_version_iduuid required,from,todate required; 200BudgetVsActualReport; 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_actualwith the three fields validates; missingbudget_version_id→InvalidParamsError{Field: "budget_version_id"}; withaccount_id→ “not taken by budget_vs_actual”;budget_version_idontrial_balance→ “not taken by trial_balance”.export_test.go:Createwith a misalignedfrom→*InvalidParamsError{Field: "from"}(mapped frombudget.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;Filenameunchanged (from/to). InCreate, afterCheckScope: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) } }whereasInvalidParamsturnsbudget.ErrNotFoundinto{Field: "budget_version_id", Reason: "names no budget version of this organization"}and a*budget.FieldErrorinto{Field: fe.Field, Reason: fe.Detail}.params.go:allowed[ReportBudgetVsActual] = {"from", "to", "budget_version_id"};present()and the field loop gainbudget_version_id;Validaterequires it for that report (and from/to as today).project.go:reportTitlesgains “Budget vs actual”;metagains the R17 fields whenx.Report == ReportBudgetVsActual(it keeps From/To);projectBudgetVsActualwrites columnsSection, Code, Account, [Analytic when AxisName != ""], Budget, Actual, Variance, % used, one row perVsActualRow, then boldTotal income,Total expense,Netrows;render.Text(*PercentUsed)or blank.project()switch gains the case.00372_report_export_budget_vs_actual.sqlper R18 (on this branch today:('trial_balance','profit_and_loss','balance_sheet','general_ledger','ap_aging','budget_vs_actual');Downwithoutbudget_vs_actual).
-
Step 4: Handler tests first (
budget_handlers_test.goin reports, on the module’senv_test.goworld plus a budget built throughbudget.Create/SaveRowsine.Tx): 200 with the Task 5 figures as strings ("-200.0000","120.0");percent_usedJSONnullfor a zero budget; 422 for misalignedfromnamingperiod_alignment; 404 for an unknownbudget_version_id; 403 withoutreports.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 viaaccountLabels→ 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; theErrUnknownReportsentence listsbudget_vs_actual.export_handlers.go’s request DTO gainsbudget_version_id.permissions.go’sreports.readdescription gains “, and budget vs actual”.routes_test.gopins the new route. -
Step 6: Run
go test ./internal/platform/reporting/... ./internal/modules/reports/... ./internal/app/... ./internal/kernel/db/... -count=1→ PASS. Sabotage: dropbudget_vs_actualfrom00372’s list → the exportCreatetest fails onreport_export_report_known; restore; record. -
Step 7: Commit.
"Budgets Task 7: budget-vs-actual report and export".
Task 8: Web — budgets list, grid, CSV
Section titled “Task 8: Web — budgets list, grid, CSV”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 (wherepayablesis registered — grep'payables'inweb/src/lib/i18n)
Interfaces:
-
Consumes: generated
schema.d.tsfrom 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/budgetsand/accounting/budgets/$budgetId(searchversion?: 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 zeroTests (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(thepayables/api.tsshape):budgetKeys,useBudgets(fiscalYearId?),useBudget(id),useBudgetVersion(id)(keepsetagfrom the response header — readresponse.headers.get('ETag')as payables does),useCreateBudget,useSaveRows(versionId),useImportCSV(versionId),useApproveVersion,useDiscardVersion,useReviseBudget— every write sendsIf-Matchfrom the cached view and, on success,setQueryDatafor the version and invalidates the budget. -
Step 3: Pages.
BudgetsPage:PageHeader(title, “New budget” whenuseCan('accounting.budget.manage')); fiscal-year filter (Selectfrom/accounting/fiscal-years);DataTabletableId="budgets"columns: name (always shown), fiscal year, axis, current version (Original/Revision n+Approvedtag), draft (tag or —). Row click →/accounting/budgets/$id.NewBudgetDialog: name, fiscal year (default: the year containing today), axis (optional; only axes whoseapplies_toincludes income or expense). On success navigate to the budget.BudgetPage: header with the budget name and fiscal year; versionSelect(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; 422errorsshown as a list in a dialog), Download CSV (toCSV→Blob→ the existingdownload.tshelper infeatures/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 (409revision_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;InputcellsinputMode="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 getaria-invalidand the error text in aTooltip; read-only (text,formatMoney) when the version is not a draft or the user lacks manage. Add row: accountComboboxover postable income/expense accounts (fromuseAccounts), and analyticComboboxover 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 withIf-Match: "3"; a 409revision_mismatchshows 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 underappRoute(theapprovers/routes.tsxshape),RequirePermission permission="accounting.ledger.read"; spread...budgetsRoutesinweb/src/app/routes.tsbesideapproversRoutes.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.nofloatandi18nparity) → PASS. Sabotage: delete onemskey →i18nparity.test.tsred; 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’sapi.ts),ExportMenu,ReportFlags(provisional). -
Produces:
ReportsSearch.budget_version?: string; export params{budget_version_id, from, to}. -
Step 1: Search and page.
routes.tsx: thereportenum gainsbudget_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 (Selectof 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_onfor From andends_onfor To; defaults P1 → the period containing today, clamped to the year);exportParamsfor 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-dangerclass and a−sign (viaformatMoney), a positive onetext-success;percent_used: nullrenders—; 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 (reuseStatementTable.tsxif 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 gainsaccounting.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}; containerserp-$STACK-pg/erp-$STACK-minio; state dir.dev-stack${STACK_NAME:+-$STACK_NAME};PORT=$API_PORTand everylocalhost:3000→localhost:$API_PORT.playwright.config.ts:const API = process.env.API_PORT ?? '3000'; const WEB = process.env.WEB_PORT ?? '5173';used inbaseURL, the health URL, the vite command (--port ${WEB}) and aSTACK_NAME/port env passed to the stack command.vite.config.ts: proxy targethttp://localhost:${process.env.API_PORT ?? '3000'}.support.tsalready readsAPI_BASE. Defaults unchanged, so the user’s dev stack behaves exactly as before.
Lane 5 run command (record in the commit body):
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 gainaccounting.budget.manageandaccounting.budget.approve; existing Finance roles gainaccounting.budget.manage;perm_versionbumps so live sessions refresh once),00372(report_exportacceptsbudget_vs_actual). No provisioning step, no configuration, no new environment variable. -
Step 5: Full suite, once.
make generate && git diff --exit-code, thengo test -p 2 -count=1 ./...andmake web. Any failure outsidebudget/reporting/reports/accountingis investigated, not skipped. -
Step 6: Commit.
"Budgets Task 10: e2e on lane 5, roadmap, deploy notes".
Self-Review
Section titled “Self-Review”Spec coverage
Section titled “Spec coverage”| 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 |
Placeholder scan
Section titled “Placeholder scan”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.
Type and name consistency
Section titled “Type and name consistency”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.