Skip to content

Plan 9 — Reports 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: An accountant reads the trial balance, profit and loss, balance sheet and general-ledger detail (filtered by an account subtree and an analytic axis) straight from the posted ledger — one read-only snapshot per report, base currency, strings on the wire — and exports any of them to CSV, XLSX or PDF through a River job enqueued in the request’s own transaction, the file landing in the in-Malaysia object store with its hash, its audit rows and seven-year retention; aged advances and the float register are specified as stubs for Plans 7 and 8.

Architecture: internal/platform/reporting is spec §10.7’s one query layer: every report is a projection of one aggregate over journal_line base amounts (or analytic_distribution.base_amount under an analytic filter) of posted entries, scoped by a date range, an account subtree (account.path prefix) and one analytic axis. The four reports are plain functions over a pgx.Tx that handlers run inside a new db.InReadOnlyTx (REPEATABLE READ, READ ONLY), so a report cannot write and sees one snapshot. General-ledger detail streams through a visitor with a keyset cursor. reporting/render turns rows into CSV (stdlib), XLSX (a small stdlib SpreadsheetML writer — exact decimal <v> cells) and PDF (signintech/gopdf + Go fonts). reporting/export owns the report_export record, the River job and the worker body. internal/kernel/jobs introduces River: its schema is vendored into goose migrations by a generator with a drift test; the API holds an insert-only client, the worker a poll-only one. internal/modules/reports is the HTTP shell for /v1/reports/….

Tech Stack: Go 1.26 · Gin · pgx v5 (raw SQL, the house pattern) · goose · sqlc (models only) · oapi-codegen · testify · testcontainers (Postgres 17, MinIO) · shopspring/decimal behind kernel/money · github.com/riverqueue/river v0.47.0 + riverdriver/riverpgxv5 v0.47.0 · github.com/signintech/gopdf v0.38.1 · golang.org/x/image/font/gofont · github.com/minio/minio-go/v7 (via Plan 5’s seam) · github.com/xuri/excelize/v2 v2.11.0 (tests only)

Specs (read before any task):

  • docs/superpowers/specs/2026-09-20-erp-architecture-design.md — §10.7 (reports, one query layer in platform/reporting), §10.1–10.4 (chart, entries, analytic axes, fiscal periods and year-end), §2 (River: Postgres-backed, enqueue in the same tx), §11A (records stay in Malaysia; seven years; readable exports; en/ms records), §15 (worker entrypoint, pgbouncer transaction pooling, migrations by a privileged job).
  • docs/superpowers/specs/2026-09-21-phase1-wave1-contracts.md — §3 (shared files are append-only), §5 (standing constraints).
  • docs/superpowers/specs/2026-09-21-phase1-wave2-contracts.md on main — §3 (Plan 4’s review lessons: named constraints, pinned search_path, lock order, test hygiene), §4 (records stay in Malaysia; the object store).
  • docs/superpowers/plans/2026-09-21-plan-4-accounting-core.md and its real code on this branch — ledger, chart, fiscal, analytic, closing (R16, R17, R25, R26, R35 matter here), accountingtest.
  • docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md on main — Task 2 (object-store config) and Task 4 (the Store seam), which Task 4 of this plan adopts or builds (Ruling R21).

Every task’s requirements implicitly include this section.

  • Go floor is 1.26.0. CI derives its version from go.mod; never restate one.
  • Ownership. Go code only under internal/platform/reporting/…, internal/modules/reports/…, internal/kernel/jobs/…, tools/riverschema/…, plus the named exceptions in Ruling R1. Never edit internal/platform/accounting/…, internal/platform/posting, internal/modules/accounting, or any other plan’s package. Plan 5’s object-store files are touched only as Ruling R21 says.
  • Ranges. Migrations 00110–00129 only; this plan uses 00110–00116 (River, generated) and 00120–00121; 00117–00119 are reserved for River upgrades. Provisioning step orders 100–109 — unused (Ruling R27). Permissions reports.* only. OpenAPI paths /reports/… only (served under /v1).
  • Shared files are append-only (wave-1 contract §3): constants.yaml permissions list, api/openapi.yaml, internal/app/app.go, internal/app/routes_test.go (composedModules), the blank imports in cmd/api, cmd/migrate, cmd/orgadmin, cmd/worker and their wiring_test.go, internal/kernel/db/retention_test.go lists, db/rls_exempt.txt, .env.example, docs/DEPLOY_NOTES.md (a new section on top), docs/ROADMAP.md (only Plan 9’s lines). Generated files (internal/gen/…, internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts, db/migrations/0011[0-6]_river_*.sql) are regenerated, never hand-merged. go.mod/go.sum conflicts are resolved by re-running each side’s go get then go mod tidy, never by hand.
  • Numbers come only from the ledger (Ruling R3). Every figure is a SUM over journal_line.base_debit / base_credit of rows in journal_entry (every row there is posted: the ledger is append-only and has no draft state), or over analytic_distribution.base_amount under an analytic filter. No report reads a source-document table (claims, advances, floats). No report recomputes an amount from a percentage, a rate or a transaction-currency amount.
  • Reports are read-only. Every report read runs inside db.InReadOnlyTx (Task 1). The only writes in this plan are report_export rows, their audit rows, River job rows and export files. No INSERT/UPDATE/DELETE appears in internal/platform/reporting outside reporting/export (Task 6’s guard).
  • Money is money.Amount (NUMERIC(19,4)), base currency only, a string on the wire (Money schema). No float32/float64 value ever holds an amount — not in SQL scanning, not in rendering, not in XLSX or PDF. The only floats allowed in reporting/render are PDF page geometry inside pdf.go (Ruling R23); Task 10’s guard enforces the rest.
  • Tenant isolation, both layers. Every SQL statement names org_id = $1 for each org-scoped table it touches (join equalities count; TestEveryTenantQueryCarriesAnExplicitOrgPredicate reads Go literals — write every query as a literal or a same-package string constant, never fmt.Sprintf), and every INSERT names org_id. report_export has ENABLE and FORCE ROW LEVEL SECURITY and an org_isolation policy with USING and WITH CHECK; its FKs to org-scoped tables are composite (org_id, x). Every platform function reads the org from tenancy.OrgFromContext; nothing takes an org argument except the River job, whose org is set into the context before any query (Ruling R17).
  • Named constraints (wave-2 §3). Every CHECK and UNIQUE in Plan 9’s own migrations is written CONSTRAINT <name> …, and no name ends in _check, _key or _excl. River’s generated migrations are exempt (Ruling R29).
  • Pinned functions (wave-2 §3). Every function or trigger function Plan 9 creates is SET search_path = pg_catalog, public, pg_temp and schema-qualifies its tables; River’s one function is pinned by ALTER FUNCTION in its generated migration (Ruling R15). TestEveryFunctionPinsItsSearchPath enforces it.
  • Every table is in exactly one list in internal/kernel/db/retention_test.go. report_export is amendable; River’s tables are operational.
  • Every state change writes audit.Write on the same pgx.Tx, after every row lock the transaction takes (wave-2 §3 lock order). The lock order in this plan: report_export row FOR UPDATE → audit.Write. This plan adds no advisory-lock class, and River is used without UniqueOpts, so River takes none either.
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …). Only handlers and the export worker open transactions. Object-store I/O happens between transactions, never inside one.
  • The business date is kernel/clock; every DATE handled in Go is midnight UTC; timestamps are written and rendered in UTC. Never time.Now() in code under test. (Whether to move to Asia/Kuala_Lumpur is an open user decision; keep every derivation behind clock.)
  • Record language (§11A.3). Exports print each account’s record-bearing name (en/ms), never a zh translation (Ruling R13).
  • Errors are RFC 7807. Handlers c.Error(apperr.New(kind, detail)) and return. Platform code returns sentinel or typed errors. Report reads never need ledger.MapError (they post nothing).
  • Every route goes through httpx.Protected with a reports.* permission registered in the module’s init(), and is documented in api/openapi.yaml in the same commit.
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first; every isolation test first shows the other org’s rows exist; every guard is watched failing once (sabotage step) and its red output is quoted in the task report.
  • Concurrency and job tests bound every wait with context.WithTimeout and clean up in t.Cleanup (stop River clients, close pools), so a regression fails instead of hanging. No t.Parallel() in packages that start River clients.
  • Tests: during development run only the packages you changed (go test ./internal/platform/reporting/ -count=1). make generate after any migration, OpenAPI or constants.yaml change. One full go test -p 2 -count=1 ./... in Task 17 only.
  • Git commits end with: Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Decisions taken where the specs are silent or ambiguous. Format: Ruling: <decision> — <why> — <cost if wrong>.

  • R1 Ruling: Packages are internal/platform/reporting (query layer + the four reports), reporting/reportingtest (the golden books, a non-test package like accountingtest), reporting/render (CSV, XLSX, PDF writers — no database), reporting/export (report_export, the River job, the worker body), internal/kernel/jobs (River client configuration and the vendored schema generator), tools/riverschema, and the module internal/modules/reports. Named exceptions outside those: internal/kernel/db/readonly.go (+ test), db/rls_exempt.txt, internal/kernel/db/retention_test.go, sqlc.yaml (none expected), go.mod/go.sum, .env.example, docs/DEPLOY_NOTES.md, cmd/api, cmd/worker — spec §10.7 names platform/reporting; River is infrastructure every later plan’s jobs share, so its configuration lives once in the kernel — moving kernel/jobs later is a mechanical rename.
  • R2 Ruling: Plan 9’s ranges are migrations 00110–00129, provisioning 100–109, permissions reports.*, paths /v1/reports/…, as the task brief assigns (the wave-1/2 contracts stop at Plan 6’s 00109) — the same partition scheme as waves 1 and 2 — a later contract that assigns differently renumbers only within this range.
  • R3 Ruling: Every figure is a sum over posted journal_line base amounts (or analytic_distribution.base_amount under an analytic filter); transaction-currency amounts appear only as informative general-ledger columns; nothing reads claims, advances or floats — the brief’s binding rule and §11A.4 (a report figure walks back to entries, entries walk back to documents) — none.
  • R4 Ruling: Report reads run in a new db.InReadOnlyTx: BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY, the same nesting and tenant rules as InTx, no event buffer — a report issues several queries (openings, movements, chart, provisional flag) that must see one snapshot, and READ ONLY makes “reports never write” a database fact — a kernel addition outside the ownership table (R1).
  • R5 Ruling (the year-end close): P&L excludes entries with semantic = 'year_end_close', so a closed year’s P&L still shows its result. The trial balance includes them (the ledger as it stands, a post-closing TB) unless exclude_closing=true, which gives the pre-closing TB. The general ledger always includes them. The balance sheet includes every entry through as_of and shows earnings not yet closed to retained earnings as two synthetic equity lines — current_year_earnings (income − expense from the start of the fiscal year containing as_of through as_of, closing entries included, so it is 0 at a closed year’s end) and prior_unclosed_earnings (the same before that fiscal year’s start, non-zero only while an earlier year is unclosed) — so assets = liabilities + equity at every date, across the close — Plan 4 R17 posts the closing entry dated ends_on into the last period; excluding it from P&L is the only reading under which “P&L for 2026” means the same thing before and after the close — a pre-close balance sheet at a closed year’s end needs an exclude_closing flag on the balance sheet later.
  • R6 Ruling: Every report takes calendar dates (from/to inclusive, or as_of), not period ids; dates need not align to period boundaries and may fall outside any fiscal year; the response carries provisional: true when any fiscal period overlapping the range (balance sheet: any period starting on or before as_of) is still open, because those figures can still change — one date form keeps one code path, the client already gets period dates from /v1/accounting/fiscal-years, and closed/locked is exactly Plan 4’s “these numbers are final” — none.
  • R7 Ruling: Opening balances are cumulative from the first entry of the ledger through from − 1, all semantics, with no per-year reset — closing.BalancesAsOf’s rule: a P&L account opens a new year at zero because the closing entry zeroed it, not because the report resets it — while an earlier year is unclosed, P&L accounts open non-zero in the TB, which is the ledger’s truth.
  • R8 Ruling: An analytic filter is one axis per request: analytic_account_id values (repeatable) must all belong to one axis, each expands to its descendants on that axis, and amounts are the stored analytic_distribution.base_amount summed per line — never a percentage applied to a line. It is accepted on P&L and GL detail only; the trial balance and balance sheet refuse it (422) — stored shares are exact and already rounded by analytic.Plan; a product across two axes would be a recomputation with its own rounding; a filtered TB or BS does not balance and is not a statement — no cross-axis (cost centre × project) figures until someone needs them.
  • R9 Ruling: An account-subtree filter (account_id: that account and everything under its path) is accepted on TB, P&L and GL detail and refused on the balance sheet; a filtered TB reports filtered: true, and its balanced is then informational — the query layer takes a subtree (§10.7); a partial balance sheet is not a balance sheet — none.
  • R10 Ruling: Presentation: income, liability and equity amounts are credit-positive; asset and expense amounts debit-positive; header accounts are subtotals of their descendants, computed in Go from chart.ListAccounts; siblings sort by code (the stored path is UUID-based and sorts arbitrarily); rows whose amount is zero are omitted, and a header is omitted when its whole subtree is. The TB is flat: postable accounts by code, omitting a row only when all six columns are zero. The GL running balance is signed debit-positive — the conventional statement layout, and the chart is small (one row per account in memory) — none.
  • R11 Ruling: GL detail is ordered (account code, account id, entry_date, posted_seq, line_no) and paged by an opaque keyset cursor (base64url JSON of those five values), 500 lines by default, 2000 at most; it lists only accounts with at least one line in the range; each account section on a page carries opening (balance just before its first line on that page) and closing (after its last). It includes every semantic (R5). Under an analytic filter a line appears once with its summed share, and tx_debit/tx_credit are null — posted_seq is Plan 4’s monotonic posting order; keyset paging costs nothing per page as the ledger grows — an account renamed between two page requests may be skipped or repeated on the second page (exports read one snapshot and are unaffected); a balance-only account appears in the TB, not the GL.
  • R12 Ruling: No unbounded in-memory result sets. TB, P&L and BS hold at most one row per account. GL detail streams row by row from the pgx result (a visitor), with opening balances preloaded per account (at most the chart’s size). A GL export is capped at 250,000 lines for CSV and XLSX and 20,000 for PDF, checked by COUNT when the export is requested (422 naming the cap) and again in the job (failed, too_large) — Plan 5’s Store.Put takes []byte (R21), so a file is held in memory once; the caps bound that to tens of megabytes — an accountant exporting a larger ledger narrows the date range or the account subtree.
  • R13 Ruling: API rows carry both name (the account’s record-language name) and label (the request locale’s translation via i18n.Load, falling back to name); exports print name only — §11A.3: an exported statement is a record and must be en/ms; zh is a UI locale — a Chinese-locale user’s export shows English or Malay account names.
  • R14 Ruling: River is github.com/riverqueue/river v0.47.0 with riverdriver/riverpgxv5. Its schema is vendored into goose migrations 00110–00116, one file per River migration version (1–7), generated from rivermigrate.AllVersions() by go run ./tools/riverschema and pinned by a drift test; each file also records its version in river_migration (the pre-v5 shape for versions 1–4, (line, version) from 5) so rivermigrate.Validate agrees with goose. One file per version because version 4 runs ALTER TYPE … ADD VALUE, which must commit before a later statement may use the value — goose stays the only migration runner, run by the privileged migrate job (§15); River’s own migrator never runs in production — a River upgrade is new generated files in the reserved 00117–00119.
  • R15 Ruling: River’s tables stay in schema public and are listed in db/rls_exempt.txt with a justification (they hold no tenant data: a job’s args are org_id and export_id, both ids; the tenant record is report_export, under RLS), and in the operational retention list (River’s cleaner deletes finished jobs after 24 h). The generated migration also runs ALTER FUNCTION river_job_state_in_bitmask(bit, river_job_state) SET search_path = pg_catalog, public, pg_temp and grants erp_app USAGE on River’s sequences (00001’s default privileges cover tables only). A separate river schema was rejected: it would pass every public-schema guard by being invisible to it — no tenant data may ever be put in a job’s args; export.JobArgs is the only job type and holds two ids.
  • R16 Ruling: The API holds an insert-only River client (no queues, no workers) and enqueues with InsertTx inside the request’s own db.InTx, so the export row, its job and its audit row commit or roll back together (§2). The worker holds a client with PollOnly: true (pgbouncer transaction pooling cannot hold LISTEN), FetchPollInterval 1 s, queue reports with 2 workers, JobTimeout 15 min, and jobs are inserted with MaxAttempts 3 and no UniqueOpts — §2 and §15 — up to one second before a queued export starts.
  • R17 Ruling: A job carries org_id; the worker puts it in the context with tenancy.WithOrg and does every read and write through InReadOnlyTx/InTx under RLS. The report_export row, visible only under its own org, is the authority: a job naming an org with no such row does nothing — the worker cannot enumerate tenants by design (Plan 4 R14), and the row was written under RLS in the request’s transaction — none.
  • R18 Ruling: An export is queued → succeeded | failed. A failure carries a closed code — too_large, invalid_parameters (a parameter no longer resolves), internal — and details go only to the log. A transient error returns to River for a retry; on the last attempt the job marks the export failed/internal first. A BEFORE UPDATE trigger keeps the request columns fixed and a terminal row final; DELETE is revoked (amendable) — §11A.2: the record of what was exported, by whom, is kept seven years — a stuck queued row is possible only if the final-attempt write itself fails; it is visible in the list.
  • R19 Ruling: Audit rows: report_export/request in the enqueue transaction (actor = the requester; report, format, normalized parameters, job id) and report_export/complete or fail in the worker’s final transaction (actor NULL = the system; object key, size, SHA-256, row count, or the failure code). On-screen report views and download-URL issuance are reads and are not audited — the brief: “who, which report, parameters” per export; no other read endpoint in the codebase audits — nobody can tell from the audit log who downloaded a file.
  • R20 Ruling: A file’s object key is reports/<org_id>/<export_id>/<filename>; its SHA-256 and size are stored on the row; no code path deletes an object or a row; bucket versioning and a 7-year object lock are deployment steps (DEPLOY_NOTES.md); downloads are presigned GETs with Content-Disposition: attachment, valid for the store’s PresignTTL; the key carries no personal data — §11A.1/§11A.2 and Plan 5 R31 — none.
  • R21 Ruling: Plan 9 depends only on its own export.Store interface { PresignGet(ctx, key, filename string, ttl time.Duration) (string, error); Put(ctx, key, contentType string, body []byte) error } — byte-identical signatures to Plan 5 Task 4’s documents.Store, so documents.Store, s3store.Store and documentstest.MemStore all satisfy it. Task 4 adopts Plan 5’s config.ObjectStore and documents seam if they are merged when it runs; otherwise it builds exactly Plan 5 Task 2’s object-store half and Task 4’s three files at Plan 5’s paths and records that in this plan’s Reconciliation record, and Plan 5’s executor then verifies and skips those steps — one adapter and one configuration, whichever plan lands first — Plan 5 inherits files it did not write, to a specification it wrote.
  • R22 Ruling: XLSX is written by a small SpreadsheetML writer on archive/zip + encoding/xml (stdlib) in render/xlsx.go: text as inline strings, money as numeric cells whose <v> is Amount.String() verbatim, one sheet, streamed row by row into the zip. github.com/xuri/excelize/v2 v2.11.0 (BSD-3, pure Go, released 2026-07) is used in tests only, to read every generated file back — excelize’s StreamWriter.SetRow writes a number only from int/float types (a decimal string becomes a text cell), and its exact path (SetCellDefault) builds the whole sheet in memory at hundreds of bytes per cell; both break a binding rule — this plan maintains a ~200-line writer; if it proves insufficient, it sits behind render.Writer and swaps in one file.
  • R23 Ruling: PDF uses github.com/signintech/gopdf v0.38.1 (MIT, pure Go, released 2026-09-12; its one dependency phpdave11/gofpdi is MIT) with the Go fonts (golang.org/x/image/font/gofont/goregular, gobold; BSD-3) embedded, A4 landscape for TB and GL, portrait for P&L and BS. go-pdf/fpdf was rejected: last release 2023 and archived. float64 appears only as page geometry inside render/pdf.go; every amount is text from render.FormatAmount — pure Go and maintained, and the Go fonts cover every Latin glyph en/ms records use (R13) — CJK text cannot render (it never reaches an export).
  • R24 Ruling: CSV is encoding/csv with a UTF-8 BOM and CRLF line ends; the file opens with key/value metadata rows (report, organization, registration no., parameters, currency, provisional, generated at, export id), one blank row, then the table; amounts are Amount.String() (exact, 4 dp, no grouping); a text cell starting with =, +, -, @, TAB or CR is prefixed with ' — Excel reads BOM-less UTF-8 as ANSI, and an account name or memo is user input that must not become a formula — a strict CSV consumer sees the BOM and the metadata block.
  • R25 Ruling: Amounts are exact everywhere: CSV and the XLSX cell value carry all 4 decimals; PDF text and the XLSX number format (#,##0.00##) group thousands and show 2 to 4 decimals, dropping only trailing zeros beyond the second — lossless — auditors trace figures to the cent and beyond; MYR shows as 1,234.50 — none.
  • R26 Ruling: Permissions are reports.read (every report endpoint, and listing, reading and downloading exports) and reports.export (requesting one). Download needs only reports.read because a reader can already see the same figures on screen — none.
  • R27 Ruling: Plan 9 adds no provisioning step (100–109 unused): reports need no per-org seed, and the Owner receives reports.* because provisioning.Run grants every registered permission — no deployed organization exists yet; one provisioned before this plan would need its Owner role granted reports.* by hand.
  • R28 Ruling: Migration 00121 adds journal_entry (org_id, entry_date, posted_seq) and analytic_distribution (org_id, analytic_account_id) indexes — additive to Plan 4’s tables, plain CREATE INDEX because no database is deployed yet (§15’s CONCURRENTLY applies after the first deployment) — every report filters entries by date and GL detail orders by posted_seq — none.
  • R29 Ruling: Plan 5’s named-constraint guard (internal/kernel/db/constraintnames_test.go, grandfathering versions ≤ 69) is not on this branch. Plan 9 names every constraint in 00120; River’s generated migrations carry River’s own, some Postgres-generated, names, and whichever of Plan 5 and Plan 9 merges second extends the guard to skip tables named river_% (recorded in Notes for later plans) — River’s DDL is vendored verbatim and renaming its constraints would break its future migrations — none.
  • R30 Ruling: Moving the BNM daily ticker, the nightly reconciliation (Plan 4 R14) and Plan 5’s idempotency-key pruner (Plan 5 R28) onto River is out of scope; this plan lands River so they can — the brief’s scope split — those ROADMAP items stay open.
  • R31 Ruling: Aged advances (needs Plan 7) and the float register (needs Plan 8) are not executable tasks here; the Deferred until Plans 7/8 section names exactly what each plan must expose — the brief’s binding split — none.
  • R32 Ruling: Export requests are neither deduplicated nor rate-limited — an export is cheap to repeat and the API’s per-user limiter (§15) bounds abuse — a member queueing many large exports delays others’ by at most the queue’s 2 workers.
  • R33 Ruling: balanced on the TB and BS is computed from the totals and returned, never turned into an error — an unfiltered imbalance can only mean ledger corruption, which the reconciliation set (trial_balance) reports with the offending rows; a 500 would hide the figures an accountant needs to investigate — none.
  • R34 Ruling: An export and the matching screen call run the same reporting function; render only formats. Task 17 asserts an exported CSV’s figures equal the JSON endpoint’s — two code paths for one figure is how a statement and its export disagree — none.

File Task Responsibility
00110_river_v001.sql … 00116_river_v007.sql 2 River’s schema, one River version per file, generated by tools/riverschema; each records its river_migration row; 00116 also pins river_job_state_in_bitmask and grants sequence USAGE.
00120_report_export.sql 5 report_export (RLS, named constraints, DELETE revoked, report_export_guard trigger).
00121_reporting_indexes.sql 5 Two read indexes on Plan 4’s ledger tables (R28).
Path Task(s) Responsibility
internal/kernel/jobs/ 2, 3 schema.go (render River’s migrations as goose files), jobs.go (Inserter, NewInserter, NewWorkerClient, QueueReports).
tools/riverschema/ 2 go run ./tools/riverschema writes db/migrations/0011[0-6]_river_v00N.sql.
internal/platform/reporting/ 6–9 Scope resolution, movements, rollup, provisional flag; TrialBalance, ProfitAndLoss, BalanceSheet, GeneralLedger, CountGeneralLedger.
internal/platform/reporting/reportingtest/ 6 Seed — the golden books every reporting, export and module test uses.
internal/platform/reporting/render/ 10, 11 Writer, Cell, Column, Meta, FormatAmount; csv.go, xlsx.go, pdf.go.
internal/platform/reporting/export/ 12, 13 Store, Params, Create, Get, List, JobArgs; project.go (report → table), run.go, worker.go.
internal/modules/reports/ 14, 15 Permissions, routes, report and export handlers.
Path Task Change
internal/kernel/db/readonly.go (new) + readonly_test.go 1 (*DB).InReadOnlyTx.
db/rls_exempt.txt 2 River tables with justification.
internal/kernel/db/retention_test.go 2, 5 River tables → operational; report_export → amendable.
go.mod, go.sum 2, 4, 10, 11 River, riverpgxv5; (minio-go per R21); excelize (test); gopdf, x/image.
internal/gen/sqlc/models.go 2, 5 Regenerated (make generate).
internal/kernel/config/objectstore.go (+ test), internal/platform/documents/{store.go,s3store,documentstest} 4 Only if Plan 5 has not landed them (R21).
.env.example, docs/DEPLOY_NOTES.md 4, 16 Object store (if not present), River worker, export retention.
constants.yaml (+ generated vocab) 14 reports.read, reports.export.
api/openapi.yaml (+ internal/gen/api) 14, 15 /reports/….
internal/app/app.go, internal/app/routes_test.go, cmd/{api,migrate,orgadmin}/main.go, cmd/{migrate,orgadmin}/wiring_test.go 14, 15 Compose the module; the API builds the River inserter and the store.
cmd/worker/main.go, cmd/worker/exports.go (new), cmd/worker/wiring_test.go 16 Start the River worker client with the export worker.
docs/ROADMAP.md 17 Split Plan 9’s report line; tick what landed.

Task 1: db.InReadOnlyTx — one read-only snapshot per report

Section titled “Task 1: db.InReadOnlyTx — one read-only snapshot per report”

Files:

  • Create: internal/kernel/db/readonly.go
  • Test: internal/kernel/db/readonly_test.go

Interfaces:

  • Consumes: db.DB, txMarkerKey, ErrNestedTx, ErrNoTenant (all in internal/kernel/db/db.go).

  • Produces:

    // InReadOnlyTx runs fn in a REPEATABLE READ, READ ONLY transaction with the
    // tenant set — every query fn issues sees one snapshot, and any write is
    // refused by Postgres (SQLSTATE 25006). Same nesting and tenant rules as
    // InTx; no event buffer, because a read publishes nothing.
    func (d *DB) InReadOnlyTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
  • Step 1: Write the failing tests — readonly_test.go (reuse seedOrg from intx_test.go, same package db_test):

func openApp(t *testing.T, h *testdb.Handles) *db.DB {
t.Helper()
d, err := db.Open(context.Background(), h.AppDSN, eventbus.New())
require.NoError(t, err)
t.Cleanup(d.Close)
return d
}
func TestInReadOnlyTxRequiresTenant(t *testing.T) {
h := testdb.Start(t)
d := openApp(t, h)
err := d.InReadOnlyTx(context.Background(), func(context.Context, pgx.Tx) error { return nil })
require.ErrorIs(t, err, db.ErrNoTenant)
}
func TestInReadOnlyTxSetsTheTenant(t *testing.T) {
h := testdb.Start(t)
org := seedOrg(t, h, "Alpha")
d := openApp(t, h)
var got string
require.NoError(t, d.InReadOnlyTx(tenancy.WithOrg(context.Background(), org),
func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT current_setting('app.current_org', true)`).Scan(&got)
}))
require.Equal(t, org.String(), got)
}
// TestInReadOnlyTxRefusesWrites: PRESENCE first — the same UPDATE succeeds
// in InTx — then the read-only transaction refuses it with 25006.
func TestInReadOnlyTxRefusesWrites(t *testing.T) {
h := testdb.Start(t)
org := seedOrg(t, h, "Alpha")
d := openApp(t, h)
ctx := tenancy.WithOrg(context.Background(), org)
const touch = `UPDATE organization SET name = name WHERE org_id = $1 AND id = $1`
require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
tag, err := tx.Exec(ctx, touch, org)
require.EqualValues(t, 1, tag.RowsAffected(), "the write reaches a row under InTx")
return err
}))
err := d.InReadOnlyTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, touch, org)
return err
})
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr)
require.Equal(t, "25006", pgErr.Code, "read_only_sql_transaction")
}
// TestInReadOnlyTxSeesOneSnapshot: a row committed by another transaction
// between two reads is invisible to the second read.
func TestInReadOnlyTxSeesOneSnapshot(t *testing.T) {
h := testdb.Start(t)
org := seedOrg(t, h, "Alpha")
d := openApp(t, h)
ctx := tenancy.WithOrg(context.Background(), org)
const read = `SELECT name FROM organization WHERE org_id = $1 AND id = $1`
require.NoError(t, d.InReadOnlyTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var first, second string
require.NoError(t, tx.QueryRow(ctx, read, org).Scan(&first))
_, err := h.Admin.Exec(context.Background(), `UPDATE organization SET name = 'Renamed' WHERE id = $1`, org)
require.NoError(t, err)
require.NoError(t, tx.QueryRow(ctx, read, org).Scan(&second))
require.Equal(t, first, second, "REPEATABLE READ: the second read sees the first read's snapshot")
return nil
}))
// PRESENCE: the rename did commit; a fresh transaction sees it.
require.NoError(t, d.InReadOnlyTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var now string
require.NoError(t, tx.QueryRow(ctx, read, org).Scan(&now))
require.Equal(t, "Renamed", now)
return nil
}))
}
func TestInReadOnlyTxRefusesToNest(t *testing.T) {
h := testdb.Start(t)
org := seedOrg(t, h, "Alpha")
d := openApp(t, h)
ctx := tenancy.WithOrg(context.Background(), org)
for name, outer := range map[string]func(context.Context, func(context.Context, pgx.Tx) error) error{
"inside InTx": d.InTx, "inside InReadOnlyTx": d.InReadOnlyTx,
} {
err := outer(ctx, func(txCtx context.Context, _ pgx.Tx) error {
return d.InReadOnlyTx(txCtx, func(context.Context, pgx.Tx) error {
panic("the nested transaction must be refused before it begins")
})
})
require.ErrorIs(t, err, db.ErrNestedTx, name)
}
// A sequential call on the same context is not nesting.
require.NoError(t, d.InReadOnlyTx(ctx, func(context.Context, pgx.Tx) error { return nil }))
}
  • Step 2: Run and watch fail. go test ./internal/kernel/db/ -run InReadOnlyTx -count=1 → FAIL (d.InReadOnlyTx undefined).

  • Step 3: Implement readonly.go:

package db
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// InReadOnlyTx runs fn in a REPEATABLE READ, READ ONLY transaction with the
// tenant set (Plan 9 Ruling R4).
//
// Reports issue several queries — opening balances, movements, the chart,
// whether a period is still open — and every one must describe the same
// ledger: REPEATABLE READ gives them one snapshot. READ ONLY makes "a report
// never writes" something Postgres enforces (SQLSTATE 25006) rather than a
// convention. set_config is not a write, so the tenant setting still works.
//
// The nesting and tenant rules are InTx's, for InTx's reasons (ErrNestedTx).
// There is no event buffer: a read publishes nothing.
func (d *DB) InReadOnlyTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error {
if ctx.Value(txMarkerKey{}) != nil {
return ErrNestedTx
}
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return ErrNoTenant
}
txCtx := context.WithValue(ctx, txMarkerKey{}, true)
tx, err := d.pool.BeginTx(txCtx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return fmt.Errorf("begin read-only: %w", err)
}
defer func() { _ = tx.Rollback(txCtx) }()
if _, err := tx.Exec(txCtx, "SELECT set_config('app.current_org', $1, true)", orgID.String()); err != nil {
return fmt.Errorf("set tenant: %w", err)
}
if err := fn(txCtx, tx); err != nil {
return err
}
return tx.Commit(txCtx)
}
  • Step 4: Run. go test ./internal/kernel/db/ -run 'InReadOnlyTx|InTx' -count=1 → PASS.

  • Step 5: Sabotage once. Change AccessMode: pgx.ReadOnly to pgx.ReadWrite; TestInReadOnlyTxRefusesWrites goes red (no error returned). Change RepeatableRead to ReadCommitted; TestInReadOnlyTxSeesOneSnapshot goes red. Restore both; quote both failures in the task report.

  • Step 6: Commit

Terminal window
git add internal/kernel/db/readonly.go internal/kernel/db/readonly_test.go
git commit -m "Add db.InReadOnlyTx: one read-only snapshot per report
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 2: River’s schema, vendored into goose

Section titled “Task 2: River’s schema, vendored into goose”

Files:

  • Create: internal/kernel/jobs/schema.go, internal/kernel/jobs/schema_test.go, tools/riverschema/main.go
  • Create (generated): db/migrations/00110_river_v001.sql … db/migrations/00116_river_v007.sql
  • Modify: go.mod, go.sum (github.com/riverqueue/river@v0.47.0, github.com/riverqueue/river/riverdriver/riverpgxv5@v0.47.0, github.com/riverqueue/river/rivertype@v0.47.0), db/rls_exempt.txt, internal/kernel/db/retention_test.go, internal/gen/sqlc/models.go (regenerated)

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces:

    package jobs
    // FirstRiverMigration is the goose version of River's version 1 (Ruling R14).
    const FirstRiverMigration = 110
    // GooseFile is one generated goose migration.
    type GooseFile struct{ Name, SQL string } // Name e.g. "00110_river_v001.sql"
    // RiverGooseMigrations renders the pinned River version's migration line
    // "main" as goose files, one per River version.
    func RiverGooseMigrations() ([]GooseFile, error)
  • Step 1: Add the dependencies.

Terminal window
go get github.com/riverqueue/river@v0.47.0 github.com/riverqueue/river/riverdriver/riverpgxv5@v0.47.0 github.com/riverqueue/river/rivertype@v0.47.0
  • Step 2: Write the failing tests — internal/kernel/jobs/schema_test.go:
package jobs_test
// TestGeneratedRiverMigrationsAreCurrent is the drift guard (AGENTS.md:
// generated code matches its source of truth): the files on disk are exactly
// what the pinned River version renders.
func TestGeneratedRiverMigrationsAreCurrent(t *testing.T) {
files, err := jobs.RiverGooseMigrations()
require.NoError(t, err)
require.Len(t, files, 7, "River v0.47.0 ships migration versions 1–7 on line main")
dir := testdb.MigrationsDir(t)
for _, f := range files {
onDisk, err := os.ReadFile(filepath.Join(dir, f.Name))
require.NoError(t, err, "%s is missing: run `go run ./tools/riverschema`", f.Name)
require.Equal(t, f.SQL, string(onDisk), "%s is stale: run `go run ./tools/riverschema`", f.Name)
}
// No stray River file the generator no longer produces.
matches, err := filepath.Glob(filepath.Join(dir, "*_river_v*.sql"))
require.NoError(t, err)
require.Len(t, matches, len(files))
}
// TestRiverAgreesTheSchemaIsCurrent: River's own migrator, pointed at a
// database goose migrated, finds every version applied and nothing to do.
func TestRiverAgreesTheSchemaIsCurrent(t *testing.T) {
h := testdb.Start(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
m, err := rivermigrate.New(riverpgxv5.New(h.Admin), nil)
require.NoError(t, err)
applied, err := m.ExistingVersions(ctx)
require.NoError(t, err)
var versions []int
for _, v := range applied {
versions = append(versions, v.Version)
}
require.Equal(t, []int{1, 2, 3, 4, 5, 6, 7}, versions)
res, err := m.Validate(ctx, nil)
require.NoError(t, err)
require.True(t, res.OK, "rivermigrate.Validate: %v", res.Messages)
}
// TestTheAppRoleCanEnqueue: erp_app can insert a job row (sequence USAGE
// granted by 00116) and River's function is pinned.
func TestTheAppRoleCanEnqueue(t *testing.T) {
h := testdb.Start(t)
ctx := context.Background()
_, err := h.App.Exec(ctx, `INSERT INTO river_job (args, kind, max_attempts, queue, state)
VALUES ('{}', 'probe', 1, 'default', 'available')`)
require.NoError(t, err)
var cfg []string
require.NoError(t, h.Admin.QueryRow(ctx,
`SELECT coalesce(proconfig, '{}') FROM pg_proc WHERE proname = 'river_job_state_in_bitmask'`).Scan(&cfg))
require.Contains(t, cfg, "search_path=pg_catalog, public, pg_temp")
}
  • Step 3: Run and watch fail. go test ./internal/kernel/jobs/ -count=1 → FAIL (package does not exist).

  • Step 4: Implement schema.go.

// Package jobs is the one place River is configured (Plan 9 Rulings R14–R16).
package jobs
import (
"fmt"
"strings"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivermigrate"
)
const FirstRiverMigration = 110
type GooseFile struct{ Name, SQL string }
// RiverGooseMigrations renders River's migration line "main" as goose files,
// one per River version, each run in goose's own transaction. River's SQL is
// copied verbatim inside one StatementBegin/End block (it contains DO blocks
// and a function body, which goose's statement splitter would cut); its
// "/* TEMPLATE: schema */" markers are SQL comments and resolve to public.
// Each file records its version in river_migration, in the shape that table
// has at that version (before v5: (version); from v5: (line, version)), so
// rivermigrate.Validate agrees with goose. The last file pins River's one
// function and grants erp_app USAGE on River's sequences (Ruling R15).
func RiverGooseMigrations() ([]GooseFile, error) {
m, err := rivermigrate.New(riverpgxv5.New(nil), nil)
if err != nil {
return nil, fmt.Errorf("jobs: river migrator: %w", err)
}
all := m.AllVersions()
out := make([]GooseFile, 0, len(all))
for i, v := range all {
if v.Version != i+1 {
return nil, fmt.Errorf("jobs: river versions are not contiguous at %d", v.Version)
}
record, unrecord := fmt.Sprintf("INSERT INTO river_migration (version) VALUES (%d);", v.Version),
fmt.Sprintf("DELETE FROM river_migration WHERE version = %d;", v.Version)
if v.Version >= 5 {
record = fmt.Sprintf("INSERT INTO river_migration (line, version) VALUES ('main', %d);", v.Version)
unrecord = fmt.Sprintf("DELETE FROM river_migration WHERE line = 'main' AND version = %d;", v.Version)
}
var b strings.Builder
b.WriteString("-- +goose Up\n")
fmt.Fprintf(&b, "-- GENERATED by `go run ./tools/riverschema` from github.com/riverqueue/river v0.47.0,\n")
fmt.Fprintf(&b, "-- migration line main, version %d (%s). Do not edit: TestGeneratedRiverMigrationsAreCurrent.\n", v.Version, v.Name)
// One block, one Exec: pgx sends an argument-less Exec over the
// simple protocol, which accepts several statements.
b.WriteString("-- +goose StatementBegin\n")
b.WriteString(strings.TrimSpace(v.SQLUp))
b.WriteString("\n-- +goose StatementEnd\n")
b.WriteString(record + "\n")
if i == len(all)-1 {
b.WriteString(riverTrailerUp)
}
b.WriteString("\n-- +goose Down\n")
if i == len(all)-1 {
b.WriteString(riverTrailerDown)
}
// The version row goes before the DDL that removes its table (v1's down drops river_migration).
b.WriteString(unrecord + "\n")
b.WriteString("-- +goose StatementBegin\n")
b.WriteString(strings.TrimSpace(v.SQLDown))
b.WriteString("\n-- +goose StatementEnd\n")
out = append(out, GooseFile{
Name: fmt.Sprintf("%05d_river_v%03d.sql", FirstRiverMigration+i, v.Version),
SQL: b.String(),
})
}
return out, nil
}
// riverTrailerUp runs after River's last version (Ruling R15): the function
// guard (TestEveryFunctionPinsItsSearchPath) and the sequences 00001's
// default privileges do not cover.
const riverTrailerUp = `ALTER FUNCTION river_job_state_in_bitmask(bit, river_job_state) SET search_path = pg_catalog, public, pg_temp;
GRANT USAGE ON SEQUENCE river_job_id_seq, river_notification_id_seq TO erp_app;
`
const riverTrailerDown = `REVOKE USAGE ON SEQUENCE river_job_id_seq, river_notification_id_seq FROM erp_app;
ALTER FUNCTION river_job_state_in_bitmask(bit, river_job_state) RESET search_path;
`

If AllVersions() returns more than 7 versions at v0.47.0, stop: the pin is wrong. If River’s v7 drops or renames a sequence named above, fix the trailer against SELECT sequencename FROM pg_sequences WHERE sequencename LIKE 'river%' and record it in the task report.

  • Step 5: Implement the generator — tools/riverschema/main.go:
// Command riverschema writes River's schema as goose migrations
// (Plan 9 Ruling R14). Run from the repository root.
package main
import (
"log"
"os"
"path/filepath"
"github.com/mcom/erp/internal/kernel/jobs"
)
func main() {
files, err := jobs.RiverGooseMigrations()
if err != nil {
log.Fatal(err)
}
for _, f := range files {
if err := os.WriteFile(filepath.Join("db", "migrations", f.Name), []byte(f.SQL), 0o644); err != nil {
log.Fatal(err)
}
log.Printf("wrote db/migrations/%s", f.Name)
}
}

Run go run ./tools/riverschema. Seven files appear, 00110_river_v001.sql … 00116_river_v007.sql.

  • Step 6: The exemptions. Append to db/rls_exempt.txt:
# River's job queue (Plan 9, 00110–00116; Rulings R14–R15). Generated from
# github.com/riverqueue/river's own migrations; not tenant-scoped because
# they hold no tenant data: a job's args are ids only (export.JobArgs:
# org_id and export_id), and the tenant record a job acts on —
# report_export — is under forced RLS, so the worker, which sets the job's
# org before any query, can reach only that org's rows (Ruling R17).
# Condition of the exemption, as for the tables above: no package under
# internal/modules names them; internal/kernel/jobs and River itself are
# their only readers and writers. Operational: River's cleaner deletes
# finished jobs (retention_test.go, operational list).
river_job
river_leader
river_migration
river_notification
river_queue

Append to the operational list in internal/kernel/db/retention_test.go:

// Plan 9: River's job queue and its bookkeeping (00110–00116). Not
// records: a job row is a work item whose outcome is recorded on
// report_export (amendable) and in audit_log. River's cleaner
// deletes completed jobs after 24 h, which needs DELETE.
// river_migration is written only by the migrate job (goose), never
// by erp_app; it keeps 00001's default grants like its neighbours.
"river_job",
"river_leader",
"river_migration",
"river_notification",
"river_queue",
  • Step 7: Regenerate and run the guards. make generate (sqlc now also emits River’s models into internal/gen/sqlc/models.go; commit them — nothing imports them). Then:
Terminal window
go test ./internal/kernel/jobs/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1

Expected PASS, including TestEveryTableIsTenantIsolated, TestExemptionFilesNameRealTables, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryFunctionPinsItsSearchPath, TestModulesNeverReferenceRLSExemptTables.

  • Step 8: Sabotage once. (a) Add a comment line to 00113_river_v004.sql → TestGeneratedRiverMigrationsAreCurrent red. (b) Delete the ALTER FUNCTION line from the trailer, regenerate → TestEveryFunctionPinsItsSearchPath red naming river_job_state_in_bitmask. (c) Remove river_queue from rls_exempt.txt → TestEveryTableIsTenantIsolated red. Restore each; quote the three failures.

  • Step 9: Commit

Terminal window
git add go.mod go.sum internal/kernel/jobs tools/riverschema db/migrations/0011*_river_v*.sql \
db/rls_exempt.txt internal/kernel/db/retention_test.go internal/gen/sqlc/models.go
git commit -m "Vendor River's schema into goose migrations 00110-00116
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 3: River clients — insert-only for the API, poll-only for the worker

Section titled “Task 3: River clients — insert-only for the API, poll-only for the worker”

Files:

  • Create: internal/kernel/jobs/jobs.go
  • Test: internal/kernel/jobs/jobs_test.go

Interfaces:

  • Consumes: River’s schema (Task 2).

  • Produces:

    package jobs
    const QueueReports = "reports"
    // Inserter is what a handler needs: enqueue inside its own transaction.
    // *river.Client[pgx.Tx] satisfies it.
    type Inserter interface {
    InsertTx(ctx context.Context, tx pgx.Tx, args river.JobArgs, opts *river.InsertOpts) (*rivertype.JobInsertResult, error)
    }
    // NewInserter is an insert-only client: no queues, no workers (Ruling R16).
    func NewInserter(pool *pgxpool.Pool) (*river.Client[pgx.Tx], error)
    type WorkerConfig struct {
    Queues map[string]int // queue -> max concurrent jobs
    FetchPollInterval time.Duration // 0: 1s
    Logger *slog.Logger // nil: slog.Default()
    }
    // NewWorkerClient is the worker's client: PollOnly (pgbouncer transaction
    // pooling cannot LISTEN), JobTimeout 15m. The caller Starts and Stops it.
    func NewWorkerClient(pool *pgxpool.Pool, workers *river.Workers, cfg WorkerConfig) (*river.Client[pgx.Tx], error)
  • Step 1: Write the failing tests — jobs_test.go:

type probeArgs struct{ N int `json:"n"` }
func (probeArgs) Kind() string { return "probe" }
type probeWorker struct {
river.WorkerDefaults[probeArgs]
done chan int
}
func (w *probeWorker) Work(_ context.Context, j *river.Job[probeArgs]) error {
w.done <- j.Args.N
return nil
}
func countJobs(t *testing.T, h *testdb.Handles) int {
t.Helper()
var n int
require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT count(*) FROM river_job WHERE kind = 'probe'`).Scan(&n))
return n
}
// TestAJobCommitsWithItsTransaction: §2's rule. PRESENCE: a committed insert
// leaves a row; ABSENCE: a rolled-back one leaves none.
func TestAJobCommitsWithItsTransaction(t *testing.T) {
h := testdb.Start(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ins, err := jobs.NewInserter(h.App)
require.NoError(t, err)
tx, err := h.App.Begin(ctx)
require.NoError(t, err)
_, err = ins.InsertTx(ctx, tx, probeArgs{N: 1}, &river.InsertOpts{Queue: jobs.QueueReports})
require.NoError(t, err)
require.NoError(t, tx.Commit(ctx))
require.Equal(t, 1, countJobs(t, h))
tx, err = h.App.Begin(ctx)
require.NoError(t, err)
_, err = ins.InsertTx(ctx, tx, probeArgs{N: 2}, &river.InsertOpts{Queue: jobs.QueueReports})
require.NoError(t, err)
require.NoError(t, tx.Rollback(ctx))
require.Equal(t, 1, countJobs(t, h), "a rolled-back request leaves no job")
}
// TestTheWorkerClientWorksAJob end to end, poll-only, bounded.
func TestTheWorkerClientWorksAJob(t *testing.T) {
h := testdb.Start(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
w := &probeWorker{done: make(chan int, 1)}
workers := river.NewWorkers()
river.AddWorker(workers, w)
client, err := jobs.NewWorkerClient(h.App, workers, jobs.WorkerConfig{
Queues: map[string]int{jobs.QueueReports: 1}, FetchPollInterval: 50 * time.Millisecond})
require.NoError(t, err)
require.NoError(t, client.Start(ctx))
t.Cleanup(func() {
stopCtx, c := context.WithTimeout(context.Background(), 10*time.Second)
defer c()
_ = client.Stop(stopCtx)
})
ins, err := jobs.NewInserter(h.App)
require.NoError(t, err)
tx, err := h.App.Begin(ctx)
require.NoError(t, err)
_, err = ins.InsertTx(ctx, tx, probeArgs{N: 7}, &river.InsertOpts{Queue: jobs.QueueReports})
require.NoError(t, err)
require.NoError(t, tx.Commit(ctx))
select {
case n := <-w.done:
require.Equal(t, 7, n)
case <-ctx.Done():
t.Fatal("the worker never picked the job up")
}
}
  • Step 2: Run and watch fail. go test ./internal/kernel/jobs/ -run 'Job|Worker' -count=1 → FAIL (undefined).

  • Step 3: Implement jobs.go.

package jobs
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivertype"
)
// QueueReports runs report exports (Ruling R16).
const QueueReports = "reports"
type Inserter interface {
InsertTx(ctx context.Context, tx pgx.Tx, args river.JobArgs, opts *river.InsertOpts) (*rivertype.JobInsertResult, error)
}
var _ Inserter = (*river.Client[pgx.Tx])(nil)
func NewInserter(pool *pgxpool.Pool) (*river.Client[pgx.Tx], error) {
return river.NewClient(riverpgxv5.New(pool), &river.Config{})
}
type WorkerConfig struct {
Queues map[string]int
FetchPollInterval time.Duration
Logger *slog.Logger
}
// jobTimeout bounds one attempt of any job; a GL export at its cap renders in
// well under a minute, so 15 minutes only ever cuts off a hung attempt.
const jobTimeout = 15 * time.Minute
func NewWorkerClient(pool *pgxpool.Pool, workers *river.Workers, cfg WorkerConfig) (*river.Client[pgx.Tx], error) {
poll := cfg.FetchPollInterval
if poll == 0 {
poll = time.Second
}
cooldown := 100 * time.Millisecond // River's default; it may not exceed the poll interval
if poll < cooldown {
cooldown = poll
}
queues := make(map[string]river.QueueConfig, len(cfg.Queues))
for q, n := range cfg.Queues {
queues[q] = river.QueueConfig{MaxWorkers: n}
}
return river.NewClient(riverpgxv5.New(pool), &river.Config{
Queues: queues,
Workers: workers,
PollOnly: true,
FetchPollInterval: poll,
FetchCooldown: cooldown,
JobTimeout: jobTimeout,
Logger: cfg.Logger,
})
}
  • Step 4: Run. go test ./internal/kernel/jobs/ -count=1 → PASS.

  • Step 5: Commit

Terminal window
git add internal/kernel/jobs/jobs.go internal/kernel/jobs/jobs_test.go go.mod go.sum
git commit -m "Configure River once: an insert-only client and a poll-only worker client
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 4: The object-store seam — adopt Plan 5’s, or build it to Plan 5’s specification

Section titled “Task 4: The object-store seam — adopt Plan 5’s, or build it to Plan 5’s specification”

Ruling R21. The deliverable is: config.ObjectStoreFromEnv, documents.Store, s3store.New/EnsureBucket, and documentstest.NewMemStore exist on this branch with exactly Plan 5’s signatures, and export.Store (Task 12) is satisfied by all three store types.

Files (only if absent — see Step 1):

  • Create: internal/kernel/config/objectstore.go, internal/kernel/config/objectstore_test.go
  • Create: internal/platform/documents/store.go, internal/platform/documents/s3store/s3store.go, internal/platform/documents/s3store/s3store_test.go, internal/platform/documents/documentstest/memstore.go, internal/platform/documents/documentstest/memstore_test.go
  • Modify: go.mod, go.sum (github.com/minio/minio-go/v7, github.com/testcontainers/testcontainers-go/modules/minio at the testcontainers version already in go.mod), .env.example, docs/DEPLOY_NOTES.md, and this plan’s Reconciliation record (bottom of this file)

Interfaces:

  • Consumes: nothing.

  • Produces (Plan 5 Task 2 and Task 4, verbatim):

    package config
    type ObjectStore struct {
    Endpoint *url.URL // scheme http|https, host[:port], no path
    Region string // default "us-east-1"
    Bucket string
    AccessKey string
    SecretKey string
    PresignTTL time.Duration // default 5m, 1m..15m
    }
    func ObjectStoreFromEnv() (ObjectStore, error)
    package documents
    type Store interface {
    PresignPut(ctx context.Context, key, contentType string, sha256 []byte, ttl time.Duration) (url string, headers map[string]string, err error)
    PresignGet(ctx context.Context, key, filename string, ttl time.Duration) (string, error)
    Open(ctx context.Context, key string) (io.ReadCloser, error)
    Put(ctx context.Context, key, contentType string, body []byte) error
    }
    var ErrObjectMissing = errors.New("documents: no object is stored at that key")
    package s3store
    func New(cfg config.ObjectStore) (*Store, error) // does not dial
    func (s *Store) EnsureBucket(ctx context.Context) error
    var _ documents.Store = (*Store)(nil)
    package documentstest
    func NewMemStore() *MemStore
    func (m *MemStore) Upload(key string, body []byte)
    func (m *MemStore) Object(key string) ([]byte, bool)
    func (m *MemStore) PresignedHeaders(key string) map[string]string
    var _ documents.Store = (*MemStore)(nil)
  • Step 1: Look before building. git fetch origin && git log origin/main --oneline -- internal/platform/documents/store.go internal/kernel/config/objectstore.go. Then:

    • If both exist on main: merge or rebase is the user’s call, not this task’s. Instead, cherry-pick nothing; record “Plan 5 seam present on main at ; this branch will consume it after the user merges main” in the Reconciliation record, and build the files below anyway, byte-identical to main’s (git show origin/main:<path> > <path> for each file listed under Files) so this branch compiles; the later merge is then a no-op for these paths. Run Step 6 and commit.
    • If neither exists: do Steps 2–6.
    • If only one exists: stop and report; do not guess.
  • Step 2: Write the failing tests — exactly Plan 5 Task 2’s objectstore_test.go (setStore, TestObjectStoreLoadsWithDefaults, TestObjectStoreRequiresEveryConnectionSetting, TestObjectStoreRefusesAMalformedEndpoint, TestObjectStoreRefusesThePlaceholderSecret, TestPresignTTLIsBounded), reproduced here:

func setStore(t *testing.T, over map[string]string) {
t.Helper()
env := map[string]string{
"OBJECT_STORE_ENDPOINT": "http://minio:9000",
"OBJECT_STORE_REGION": "",
"OBJECT_STORE_BUCKET": "erp-documents",
"OBJECT_STORE_ACCESS_KEY": "erp",
"OBJECT_STORE_SECRET_KEY": "s3cr3t-not-a-placeholder",
"OBJECT_STORE_PRESIGN_TTL": "",
}
for k, v := range over {
env[k] = v
}
for k, v := range env {
t.Setenv(k, v)
}
}
func TestObjectStoreLoadsWithDefaults(t *testing.T) {
setStore(t, nil)
s, err := config.ObjectStoreFromEnv()
require.NoError(t, err)
require.Equal(t, "minio:9000", s.Endpoint.Host)
require.Equal(t, "us-east-1", s.Region)
require.Equal(t, 5*time.Minute, s.PresignTTL)
}
func TestObjectStoreRequiresEveryConnectionSetting(t *testing.T) {
for _, name := range []string{"OBJECT_STORE_ENDPOINT", "OBJECT_STORE_BUCKET",
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY"} {
t.Run(name, func(t *testing.T) {
setStore(t, map[string]string{name: ""})
_, err := config.ObjectStoreFromEnv()
require.ErrorContains(t, err, name)
})
}
}
func TestObjectStoreRefusesAMalformedEndpoint(t *testing.T) {
for _, bad := range []string{"minio:9000", "ftp://minio", "http://minio:9000/bucket", "https://"} {
setStore(t, map[string]string{"OBJECT_STORE_ENDPOINT": bad})
_, err := config.ObjectStoreFromEnv()
require.ErrorContains(t, err, "OBJECT_STORE_ENDPOINT", bad)
}
}
func TestObjectStoreRefusesThePlaceholderSecret(t *testing.T) {
setStore(t, map[string]string{"OBJECT_STORE_SECRET_KEY": "REPLACE_ME_WITH_THE_MINIO_SECRET"})
_, err := config.ObjectStoreFromEnv()
require.ErrorContains(t, err, "OBJECT_STORE_SECRET_KEY")
}
func TestPresignTTLIsBounded(t *testing.T) {
setStore(t, map[string]string{"OBJECT_STORE_PRESIGN_TTL": "10m"})
s, err := config.ObjectStoreFromEnv()
require.NoError(t, err)
require.Equal(t, 10*time.Minute, s.PresignTTL)
for _, bad := range []string{"30s", "16m", "forever"} {
setStore(t, map[string]string{"OBJECT_STORE_PRESIGN_TTL": bad})
_, err := config.ObjectStoreFromEnv()
require.ErrorContains(t, err, "OBJECT_STORE_PRESIGN_TTL", bad)
}
}

And Plan 5 Task 4’s two store tests: s3store_test.go (TestAPresignedPutRoundTripsAndTheStoreChecksTheHash against minio.Run(ctx, "minio/minio:RELEASE.2025-09-07T16-13-09Z") with a 90 s context and t.Cleanup termination; plus TestAPresignedGetIsAnAttachment: Put an object, PresignGet(key, "receipt.pdf"), GET it: 200, body equal, Content-Disposition contains attachment and receipt.pdf), and memstore_test.go (PresignPut records Content-Type and x-amz-checksum-sha256 = base64 of the digest; Open before Upload → ErrObjectMissing; after Upload returns the bytes; Put then Object returns the bytes; PresignGet returns a URL containing the key). The full TestAPresignedPutRoundTripsAndTheStoreChecksTheHash body is in Plan 5’s plan on main, Task 4 Step 1 (git show origin/main:docs/superpowers/plans/2026-09-21-plan-5-expense-claims.md); copy it unchanged, including its fallback note about MinIO checksum enforcement.

  • Step 3: Run and watch fail. go test ./internal/kernel/config/ ./internal/platform/documents/... -count=1 → FAIL (undefined).

  • Step 4: Implement — Plan 5 Task 2 Step 3 (object-store half only; not ocr.go) and Task 4 Step 4:

    • objectstore.go: read the six variables with literal os.Getenv("…") calls (TestEnvReadsHaveLiteralArguments). Endpoint: url.Parse, scheme http/https, non-empty host, path "" or "/", no query. Secret: refuse any value starting REPLACE_ME with “OBJECT_STORE_SECRET_KEY carries the .env.example placeholder; set the store’s real secret”. TTL: time.ParseDuration, empty → 5m, outside [1m, 15m] refused. Doc comment: the bucket, its volumes and every backup stay in Malaysia (ITA 1967 s.82(8)); there is no default endpoint because an SDK default is AWS global.
    • documents/store.go: the interface and ErrObjectMissing above, with Plan 5’s doc comments.
    • s3store.New: minio.New(u.Host, &minio.Options{Creds: credentials.NewStaticV4(access, secret, ""), Secure: u.Scheme == "https", Region: region}). PresignPut: client.PresignHeader(ctx, http.MethodPut, bucket, key, ttl, nil, http.Header{"Content-Type": {ct}, "X-Amz-Checksum-Sha256": {b64}}) and return those two headers. PresignGet: PresignedGetObject with reqParams response-content-disposition=attachment; filename="<sanitized>" (strip " and control characters). Open: GetObject then Stat(); minio.ToErrorResponse(err).Code == "NoSuchKey" → ErrObjectMissing. Put: PutObject(ctx, bucket, key, bytes.NewReader(body), int64(len(body)), minio.PutObjectOptions{ContentType: ct}). EnsureBucket: BucketExists → MakeBucket with the region.
    • documentstest.MemStore: a mutex-guarded map[string][]byte plus the recorded presigned headers; PresignPut records headers and returns "mem://put/" + key; PresignGet returns "mem://get/" + key + "?filename=" + url.QueryEscape(filename); Upload stores bytes for a key that had a PresignPut; Put stores unconditionally.
    • Append to .env.example Plan 5 Task 2 Step 4’s object-store block (the six OBJECT_STORE_* lines and their comments; not OCR_PROVIDER), with its first comment line reading “Object store for documents and report exports”.
    • docs/DEPLOY_NOTES.md, new top section “Plan 9 — object store (shared with Plan 5)”: the api and worker containers refuse to boot without the four OBJECT_STORE_* connection settings; run MinIO on the same VPS/datacenter; create the bucket; enable versioning and object lock (governance mode, 7 years) so nothing can erase a document or a report export under ITA s.82A; back the bucket up only to storage in Malaysia.
  • Step 5: Record the reconciliation. Fill the Reconciliation record at the bottom of this plan: which branch (adopted from main at <sha>, or built here), and the instruction for Plan 5’s executor: “Plan 5 Task 2 (object-store half) and Task 4 landed with Plan 9 Task 4 on branch plan-9-reports at <sha>; verify the files are identical to Plan 5’s specification and skip those steps.”

  • Step 6: Run and commit. go test ./internal/kernel/config/ ./internal/platform/documents/... -count=1 → PASS; TestEveryExampleEntryIsRead, TestEveryEnvVarIsInExample, TestCopyingTheExampleDoesNotBoot stay green.

Terminal window
git add internal/kernel/config/objectstore.go internal/kernel/config/objectstore_test.go \
internal/platform/documents go.mod go.sum .env.example docs/DEPLOY_NOTES.md \
docs/superpowers/plans/2026-09-21-plan-9-reports.md
git commit -m "Land the in-Malaysia object-store seam to Plan 5's specification
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 5: Migrations 00120–00121 — report_export and two read indexes

Section titled “Task 5: Migrations 00120–00121 — report_export and two read indexes”

Files:

  • Create: db/migrations/00120_report_export.sql, db/migrations/00121_reporting_indexes.sql, internal/platform/reporting/export/doc.go (package comment only: “Package export owns report_export, the River job that fills it, and the files it produces (Plan 9 Rulings R17–R21).” — so the directory holds a real package before Task 12)
  • Test: internal/platform/reporting/export/schema_test.go
  • Modify: internal/kernel/db/retention_test.go (append report_export to amendable), internal/gen/sqlc/models.go (regenerated)

Interfaces:

  • Consumes: Plan 4’s organization_member, journal_entry, analytic_distribution.

  • Produces: table report_export (columns below), its constraint names (Task 12 maps none of them — every write is Go-validated first — but tests assert them), trigger report_export_guard raising CONSTRAINT = 'report_export_immutable'.

  • Step 1: Write the failing test — internal/platform/reporting/export/schema_test.go (package export_test; seed via accountingtest.New(t) and e.SeedMember; write through e.H.App inside a transaction that sets app.current_org, via a helper):

// asTenant runs sql as erp_app under e's org and returns its error.
func asTenant(t *testing.T, e *accountingtest.Env, sql string, args ...any) error {
t.Helper()
return e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, sql, args...)
return err
})
}
const insertQueued = `INSERT INTO report_export (id, org_id, report, format, params, requested_by, requested_at, river_job_id)
VALUES ($1, $2, 'trial_balance', 'csv', '{"from":"2026-01-01","to":"2026-12-31"}', $3, now(), 1)`
func TestAnExportMovesOnceFromQueuedToATerminalState(t *testing.T) {
e := accountingtest.New(t)
m := e.SeedMember(t, "Requester")
id := uuid.New()
require.NoError(t, asTenant(t, e, insertQueued, id, e.Org, m))
// PRESENCE: queued → succeeded is allowed, with every outcome column.
require.NoError(t, asTenant(t, e, `UPDATE report_export SET status = 'succeeded', finished_at = now(),
object_key = 'k', content_type = 'text/csv', byte_size = 3, sha256 = $2, row_count = 1
WHERE org_id = $1 AND id = $3`, e.Org, make([]byte, 32), id))
// ABSENCE: a terminal row is final.
err := asTenant(t, e, `UPDATE report_export SET status = 'failed', failure_code = 'internal', object_key = NULL
WHERE org_id = $1 AND id = $2`, e.Org, id)
requireConstraint(t, err, "report_export_immutable")
}
func TestTheRequestColumnsAreFixed(t *testing.T) {
e := accountingtest.New(t)
m := e.SeedMember(t, "Requester")
id := uuid.New()
require.NoError(t, asTenant(t, e, insertQueued, id, e.Org, m))
for col, set := range map[string]string{
"report": "report = 'balance_sheet'", "format": "format = 'pdf'", "params": `params = '{}'`,
"requested_at": "requested_at = now() - interval '1 day'", "river_job_id": "river_job_id = 2",
} {
err := asTenant(t, e, `UPDATE report_export SET `+set+` WHERE org_id = $1 AND id = $2`, e.Org, id)
requireConstraint(t, err, "report_export_immutable", col)
}
}
func TestTheOutcomeColumnsMatchTheStatus(t *testing.T) {
e := accountingtest.New(t)
m := e.SeedMember(t, "Requester")
id := uuid.New()
require.NoError(t, asTenant(t, e, insertQueued, id, e.Org, m))
err := asTenant(t, e, `UPDATE report_export SET status = 'succeeded', finished_at = now() WHERE org_id = $1 AND id = $2`, e.Org, id)
requireConstraint(t, err, "report_export_outcome")
err = asTenant(t, e, `UPDATE report_export SET status = 'failed', finished_at = now(), failure_code = 'bogus' WHERE org_id = $1 AND id = $2`, e.Org, id)
requireConstraint(t, err, "report_export_failure_code_known")
}
func TestAnExportCannotBeDeleted(t *testing.T) {
e := accountingtest.New(t)
m := e.SeedMember(t, "Requester")
id := uuid.New()
require.NoError(t, asTenant(t, e, insertQueued, id, e.Org, m))
var pgErr *pgconn.PgError
err := asTenant(t, e, `DELETE FROM report_export WHERE org_id = $1 AND id = $2`, e.Org, id)
require.ErrorAs(t, err, &pgErr)
require.Equal(t, "42501", pgErr.Code, "insufficient_privilege: DELETE is revoked")
}
// TestAnExportIsInvisibleToAnotherOrg: PRESENCE under its own org, ABSENCE
// under another.
func TestAnExportIsInvisibleToAnotherOrg(t *testing.T) {
e := accountingtest.New(t)
other := e.Another(t, "Other Org")
m := e.SeedMember(t, "Requester")
id := uuid.New()
require.NoError(t, asTenant(t, e, insertQueued, id, e.Org, m))
count := func(env *accountingtest.Env) int {
var n int
require.NoError(t, env.DB.InTx(env.Ctx, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT count(*) FROM report_export WHERE id = $1`, id).Scan(&n)
}))
return n
}
require.Equal(t, 1, count(e))
require.Equal(t, 0, count(other))
}
func requireConstraint(t *testing.T, err error, name string, msg ...any) {
t.Helper()
var pgErr *pgconn.PgError
require.ErrorAs(t, err, &pgErr, msg...)
require.Equal(t, name, pgErr.ConstraintName, msg...)
}
  • Step 2: Run and watch fail. go test ./internal/platform/reporting/export/ -count=1 → FAIL (relation "report_export" does not exist).

  • Step 3: Write 00120_report_export.sql.

-- +goose Up
-- One requested report export (Plan 9, spec 10.7): what was asked for, by
-- whom, and the file it produced. The record of an export is kept seven
-- years (spec 11A.2): DELETE is revoked, a terminal row is final, and the
-- request columns never change (Rulings R18–R20).
CREATE TABLE report_export (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
report TEXT NOT NULL CONSTRAINT report_export_report_known CHECK (report IN
('trial_balance','profit_and_loss','balance_sheet','general_ledger')),
format TEXT NOT NULL CONSTRAINT report_export_format_known CHECK (format IN ('csv','xlsx','pdf')),
-- The normalized request parameters (export.Params), exactly as the job
-- will read them.
params JSONB NOT NULL CONSTRAINT report_export_params_object CHECK (jsonb_typeof(params) = 'object'),
status TEXT NOT NULL DEFAULT 'queued' CONSTRAINT report_export_status_known CHECK (status IN ('queued','succeeded','failed')),
requested_by UUID NOT NULL,
requested_at TIMESTAMPTZ NOT NULL,
river_job_id BIGINT NOT NULL,
finished_at TIMESTAMPTZ,
object_key TEXT,
content_type TEXT,
byte_size BIGINT CONSTRAINT report_export_byte_size_positive CHECK (byte_size > 0),
sha256 BYTEA CONSTRAINT report_export_sha256_length CHECK (length(sha256) = 32),
row_count BIGINT CONSTRAINT report_export_row_count_nonnegative CHECK (row_count >= 0),
failure_code TEXT CONSTRAINT report_export_failure_code_known CHECK (failure_code IN ('too_large','invalid_parameters','internal')),
CONSTRAINT report_export_org_id_id UNIQUE (org_id, id),
FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id),
CONSTRAINT report_export_outcome CHECK (
(status = 'queued' AND finished_at IS NULL AND object_key IS NULL AND failure_code IS NULL)
OR (status = 'succeeded' AND finished_at IS NOT NULL AND object_key IS NOT NULL AND content_type IS NOT NULL
AND byte_size IS NOT NULL AND sha256 IS NOT NULL AND row_count IS NOT NULL
AND failure_code IS NULL)
OR (status = 'failed' AND finished_at IS NOT NULL AND object_key IS NULL AND failure_code IS NOT NULL))
);
CREATE INDEX report_export_requested_idx ON report_export (org_id, requested_at DESC, id DESC);
ALTER TABLE report_export ENABLE ROW LEVEL SECURITY;
ALTER TABLE report_export FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON report_export
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON report_export FROM erp_app;
-- +goose StatementBegin
-- A terminal export is final, and what was requested never changes.
-- Pinned search_path (wave-2 contract §3).
CREATE FUNCTION report_export_guard() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF OLD.status <> 'queued'
OR NEW.id IS DISTINCT FROM OLD.id OR NEW.org_id IS DISTINCT FROM OLD.org_id
OR NEW.report IS DISTINCT FROM OLD.report OR NEW.format IS DISTINCT FROM OLD.format
OR NEW.params IS DISTINCT FROM OLD.params OR NEW.requested_by IS DISTINCT FROM OLD.requested_by
OR NEW.requested_at IS DISTINCT FROM OLD.requested_at OR NEW.river_job_id IS DISTINCT FROM OLD.river_job_id THEN
RAISE EXCEPTION 'report export % is final or its request columns changed', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'report_export_immutable';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER report_export_guard BEFORE UPDATE ON report_export
FOR EACH ROW EXECUTE FUNCTION report_export_guard();
-- +goose Down
DROP TABLE report_export;
DROP FUNCTION report_export_guard();
  • Step 4: Write 00121_reporting_indexes.sql (Ruling R28):
-- +goose Up
-- Every report filters entries by date; GL detail orders a date's entries
-- by posting order.
CREATE INDEX journal_entry_date_seq_idx ON journal_entry (org_id, entry_date, posted_seq);
-- An analytic filter starts from the analytic accounts it names.
CREATE INDEX analytic_distribution_account_idx ON analytic_distribution (org_id, analytic_account_id);
-- +goose Down
DROP INDEX analytic_distribution_account_idx;
DROP INDEX journal_entry_date_seq_idx;
  • Step 5: Retention list and generate. Append to amendable in retention_test.go:
// Plan 9: the record of a report export — who asked for which
// report with which parameters, and the hash of the file produced
// (spec 11A.2). The worker amends status once (queued → succeeded or
// failed); report_export_guard keeps it final after that.
"report_export",

make generate.

  • Step 6: Run. go test ./internal/platform/reporting/export/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1 → PASS (including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryFunctionPinsItsSearchPath, the retention test).

  • Step 7: Sabotage once. Comment out REVOKE DELETE → TestAnExportCannotBeDeleted and the retention test red. Remove OLD.status <> 'queued' OR → TestAnExportMovesOnceFromQueuedToATerminalState red. Restore; quote both.

  • Step 8: Commit

Terminal window
git add db/migrations/00120_report_export.sql db/migrations/00121_reporting_indexes.sql \
internal/platform/reporting/export/doc.go internal/platform/reporting/export/schema_test.go internal/kernel/db/retention_test.go internal/gen/sqlc/models.go
git commit -m "Add report_export, final once terminal, and two ledger read indexes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 6: The query layer and the golden books

Section titled “Task 6: The query layer and the golden books”

Files:

  • Create: internal/platform/reporting/doc.go, scope.go, movements.go, tree.go, errors.go
  • Create: internal/platform/reporting/reportingtest/golden.go
  • Test: internal/platform/reporting/helpers_test.go (shared by Tasks 7–9’s tests), movements_test.go, tree_test.go, readonly_guard_test.go, internal/platform/reporting/reportingtest/golden_test.go

Interfaces:

  • Consumes: db.InReadOnlyTx (Task 1, tests only); Plan 4: chart.ListAccounts, chart.Account, chart.Type*, company.Get (Profile.BaseCurrency), ledger.Post, ledger.Reverse, closing.ClosePeriod, closing.CloseYear, fiscal.CreateYear, reconcile.Default, analytic.Share, accountingtest.{New, Env, SeedBooks, SeedMember, Date}.
  • Produces — Tasks 7–9, 12–15 use these exact names:
    package reporting
    type Range struct{ From, To time.Time } // inclusive calendar days, midnight UTC
    func (r Range) Validate() error // ErrInvalidRange when From > To or either is zero
    type Scope struct {
    AccountID *uuid.UUID // subtree root; nil = whole chart
    AnalyticAccountIDs []uuid.UUID // one axis, descendants included; empty = unfiltered
    }
    func (s Scope) Filtered() bool
    // CheckScope resolves s without running a report: ErrAccountNotFound,
    // ErrAnalyticAccountNotFound or ErrAnalyticAxesMixed (export requests, Task 12).
    func CheckScope(ctx context.Context, tx pgx.Tx, s Scope) error
    type AccountRef struct {
    ID uuid.UUID
    ParentID *uuid.UUID
    Code, Name, Type string
    Depth int
    Postable bool
    }
    type StatementRow struct {
    Account AccountRef
    Header bool // !Account.Postable
    Amount money.Amount // presentation sign (Ruling R10)
    }
    var (
    ErrInvalidRange = errors.New("reporting: from must be a date on or before to")
    ErrAccountNotFound = errors.New("reporting: no such account")
    ErrAnalyticAccountNotFound = errors.New("reporting: no such analytic account")
    ErrAnalyticAxesMixed = errors.New("reporting: analytic accounts must all belong to one axis")
    ErrBadCursor = errors.New("reporting: the cursor is not one this report issued")
    )
    // unexported, same package (Tasks 7–9):
    type chartView struct{ /* byID, children sorted by code */ }
    func loadChart(ctx context.Context, tx pgx.Tx) (chartView, error)
    func (cv chartView) ref(id uuid.UUID) AccountRef
    func (cv chartView) postableByCode() []chart.Account
    func (cv chartView) rollup(leaf map[uuid.UUID]money.Amount, accType string) (rows []StatementRow, total money.Amount)
    type resolvedScope struct{ pathPrefix string; analyticIDs []uuid.UUID }
    func resolveScope(ctx context.Context, tx pgx.Tx, cv chartView, s Scope) (resolvedScope, error)
    type movement struct{ Debit, Credit money.Amount }
    type movementQuery struct {
    From *time.Time; To time.Time; ExcludeClosing bool; Types []string; Scope resolvedScope
    }
    func movements(ctx context.Context, tx pgx.Tx, q movementQuery) (map[uuid.UUID]movement, error)
    func lookup(ms map[uuid.UUID]movement, id uuid.UUID) movement // zeros for a missing key
    func (m movement) net() money.Amount // Debit − Credit
    func provisional(ctx context.Context, tx pgx.Tx, from *time.Time, to time.Time) (bool, error)
    func baseCurrency(ctx context.Context, tx pgx.Tx) (string, error)
    func dayBefore(d time.Time) time.Time
    package reportingtest
    type Options struct{ CloseFY2026 bool }
    type Golden struct {
    Env *accountingtest.Env
    Books accountingtest.Books
    Member uuid.UUID
    ShareCapital, Office, Stationery chart.Account
    Entries map[string]uuid.UUID // "E1".."E11", and "CLOSE" when closed
    FY2027 fiscal.Year
    }
    func Seed(t *testing.T, e *accountingtest.Env, o Options) Golden

The golden books (base MYR; every later reporting test asserts against these figures — copy them, do not recompute them differently):

Entry Date Lines (base) Analytic
E1 2026-01-02 Dr Bank 1520 10000 / Cr Share capital 3100 10000 —
E2 2026-02-10 Dr Bank 5000 / Cr Revenue 4100 5000 Revenue line: HQ 60 % (3000), OPS 40 % (2000)
E3 2026-03-15 Dr Travel 6100 1200 / Cr Bank 1200 Travel line: HQ 100 %
E4 2026-03-18 Dr Cash 1510 500 / Cr Bank 500 —
E5 2026-03-20 Dr Meals 6110 300.5 / Cr Cash 300.5 Meals line: OPS 100 %
E6 2026-04-05 Dr Stationery 6510 99.99 / Cr Bank 99.99 —
E7 2026-04-30 reversal of E6: Dr Bank 99.99 / Cr Stationery 99.99 —
E8 2026-05-10 Dr Stationery 45 / Cr Bank 45 —
E9 2026-09-18 USD 100 at 4.2000: Dr Travel 420 / Cr Bank 420 Travel line: HQ 50 % (210), OPS 50 % (210)
CLOSE 2026-12-31 (only with CloseFY2026) Dr Revenue 5000 / Cr Travel 1620, Meals 300.5, Stationery 45, Retained earnings 3200 3034.5 —
E10 2027-01-15 Dr Travel 200 / Cr Bank 200 Travel line: project PA 100 %
E11 2027-01-20 Dr Bank 800 / Cr Revenue 800 —

FY2026 net profit 3034.5000 (income 5000; expenses Travel 1620, Meals 300.5, Stationery 45 = 1965.5). Bank at 2026-12-31: 12835.0000 Dr (debits 15099.99, credits 2264.99). Cash 199.5000 Dr. FY2027 through 2027-01-31: income 800, expenses 200.

  • Step 1: Write the golden fixture’s own test — reportingtest/golden_test.go:
// TestTheGoldenBooksAreWhatTheTableSays: the fixture's figures, read back
// through closing.BalancesAsOf (Plan 4's own reader), so every later test
// can trust the table in the plan.
func TestTheGoldenBooksAreWhatTheTableSays(t *testing.T) {
for _, closed := range []bool{false, true} {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: closed})
var bal []closing.AccountBalance
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
bal, err = closing.BalancesAsOf(ctx, tx, accountingtest.Date("2026-12-31"))
return err
})
byCode := map[string]string{}
for _, b := range bal {
if b.BaseDebit.IsPositive() {
byCode[b.Code] = "Dr " + b.BaseDebit.String()
} else {
byCode[b.Code] = "Cr " + b.BaseCredit.String()
}
}
require.Equal(t, "Dr 12835.0000", byCode["1520"])
require.Equal(t, "Dr 199.5000", byCode["1510"])
require.Equal(t, "Cr 10000.0000", byCode["3100"])
if closed {
require.Equal(t, "Cr 3034.5000", byCode["3200"])
require.NotContains(t, byCode, "4100", "the close zeroed revenue")
require.Contains(t, g.Entries, "CLOSE")
} else {
require.Equal(t, "Cr 5000.0000", byCode["4100"])
require.Equal(t, "Dr 1620.0000", byCode["6100"])
require.NotContains(t, g.Entries, "CLOSE")
}
require.Len(t, g.Entries, map[bool]int{false: 11, true: 12}[closed])
}
}
  • Step 2: Run and watch fail. go test ./internal/platform/reporting/reportingtest/ -count=1 → FAIL (package does not exist).

  • Step 3: Implement reportingtest/golden.go.

// Package reportingtest seeds the golden books (Plan 9 Task 6's table) that
// every reporting, export and module test asserts against. An ordinary
// package, like accountingtest, so its callers in several packages can share
// it; it writes only through Plan 4's APIs, never raw SQL.
package reportingtest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/accounting/accountingtest"
"github.com/mcom/erp/internal/platform/accounting/analytic"
"github.com/mcom/erp/internal/platform/accounting/chart"
"github.com/mcom/erp/internal/platform/accounting/closing"
"github.com/mcom/erp/internal/platform/accounting/fiscal"
"github.com/mcom/erp/internal/platform/accounting/ledger"
"github.com/mcom/erp/internal/platform/accounting/reconcile"
)
type Options struct{ CloseFY2026 bool }
type Golden struct {
Env *accountingtest.Env
Books accountingtest.Books
Member uuid.UUID
ShareCapital, Office, Stationery chart.Account
Entries map[string]uuid.UUID
FY2027 fiscal.Year
}
func amt(s string) money.Amount { return money.MustParse(s) }
func Dr(a chart.Account, s string, shares ...analytic.Share) ledger.Line {
return ledger.Line{AccountID: a.ID, Debit: amt(s), Credit: money.Zero(), Distribution: shares}
}
func Cr(a chart.Account, s string, shares ...analytic.Share) ledger.Line {
return ledger.Line{AccountID: a.ID, Debit: money.Zero(), Credit: amt(s), Distribution: shares}
}
func Share(a analytic.Account, pct string) analytic.Share {
return analytic.Share{AnalyticAccountID: a.ID, Percentage: money.MustParseRate(pct)}
}
// Post writes one manual entry on the misc journal in its own transaction.
// currency "" is the base currency.
func (g *Golden) Post(t *testing.T, key, day, currency string, lines ...ledger.Line) uuid.UUID {
t.Helper()
var id uuid.UUID
g.Env.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
e, err := ledger.Post(ctx, tx, g.Env.Clock, ledger.EntryInput{
JournalID: &g.Books.Journals.Misc.ID, EntryDate: accountingtest.Date(day), Currency: currency,
Description: "Golden " + key, Semantic: ledger.SemanticManual, PostedBy: &g.Member, Lines: lines,
})
id = e.ID
return err
})
if key != "" {
g.Entries[key] = id
}
return id
}
func Seed(t *testing.T, e *accountingtest.Env, o Options) Golden {
t.Helper()
g := Golden{Env: e, Books: e.SeedBooks(t), Member: e.SeedMember(t, "Accountant"), Entries: map[string]uuid.UUID{}}
ch, ax := g.Books.Chart, g.Books.Axes
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
mk := func(parent chart.Account, in chart.AccountInput) chart.Account {
in.ParentID = &parent.ID
a, err := chart.CreateAccount(ctx, tx, e.Clock, in)
require.NoError(t, err)
return a
}
g.ShareCapital = mk(ch.Equity, chart.AccountInput{Code: "3100", Name: "Share capital", Type: chart.TypeEquity})
g.Office = mk(ch.Expenses, chart.AccountInput{Code: "6500", Name: "Office", Type: chart.TypeExpense, Header: true})
g.Stationery = mk(g.Office, chart.AccountInput{Code: "6510", Name: "Stationery", Type: chart.TypeExpense})
return nil
})
g.Post(t, "E1", "2026-01-02", "", Dr(ch.Bank, "10000"), Cr(g.ShareCapital, "10000"))
g.Post(t, "E2", "2026-02-10", "", Dr(ch.Bank, "5000"), Cr(ch.Revenue, "5000", Share(ax.HQ, "60"), Share(ax.Ops, "40")))
g.Post(t, "E3", "2026-03-15", "", Dr(ch.Travel, "1200", Share(ax.HQ, "100")), Cr(ch.Bank, "1200"))
g.Post(t, "E4", "2026-03-18", "", Dr(ch.Cash, "500"), Cr(ch.Bank, "500"))
g.Post(t, "E5", "2026-03-20", "", Dr(ch.Meals, "300.5", Share(ax.Ops, "100")), Cr(ch.Cash, "300.5"))
e6 := g.Post(t, "E6", "2026-04-05", "", Dr(g.Stationery, "99.99"), Cr(ch.Bank, "99.99"))
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
r, err := ledger.Reverse(ctx, tx, e.Clock, ledger.ReverseInput{
EntryID: e6, EntryDate: accountingtest.Date("2026-04-30"), PostedBy: &g.Member})
g.Entries["E7"] = r.ID
return err
})
g.Post(t, "E8", "2026-05-10", "", Dr(g.Stationery, "45"), Cr(ch.Bank, "45"))
g.Post(t, "E9", "2026-09-18", "USD", Dr(ch.Travel, "100", Share(ax.HQ, "50"), Share(ax.Ops, "50")), Cr(ch.Bank, "100"))
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
g.FY2027, _, err = fiscal.CreateYear(ctx, tx, e.Clock, accountingtest.Date("2027-01-01"))
return err
})
if o.CloseFY2026 {
for _, p := range g.Books.Periods[:11] {
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := closing.ClosePeriod(ctx, tx, e.Clock, reconcile.Default(), p.ID, &g.Member)
return err
})
}
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
res, err := closing.CloseYear(ctx, tx, e.Clock, reconcile.Default(), g.Books.Year.ID, &g.Member)
if err == nil && res.ClosingEntryID != nil {
g.Entries["CLOSE"] = *res.ClosingEntryID
}
return err
})
}
g.Post(t, "E10", "2027-01-15", "", Dr(ch.Travel, "200", Share(ax.ProjA, "100")), Cr(ch.Bank, "200"))
g.Post(t, "E11", "2027-01-20", "", Dr(ch.Bank, "800"), Cr(ch.Revenue, "800"))
return g
}
// Date is accountingtest.Date, re-exported so callers need one import.
func Date(s string) time.Time { return accountingtest.Date(s) }

If Post with Currency: "USD" needs the rate for 2026-09-18, SeedBooks already seeded USD 4.2000 on that date. If ledger.Post requires Debit/Credit zero values to be money.Zero() rather than the zero struct, the helpers above already set them.

  • Step 4: Run. go test ./internal/platform/reporting/reportingtest/ -count=1 → PASS.

  • Step 5: Write the failing query-layer tests. helpers_test.go (package reporting; Tasks 7–9’s tests use these, so they live here, not in any one report’s test file):

func readTx(t *testing.T, e *accountingtest.Env, fn func(ctx context.Context, tx pgx.Tx)) {
t.Helper()
require.NoError(t, e.DB.InReadOnlyTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error { fn(ctx, tx); return nil }))
}
// fy is fiscal (= calendar) year y of the golden books.
func fy(y string) Range {
return Range{From: reportingtest.Date(y + "-01-01"), To: reportingtest.Date(y + "-12-31")}
}
// stmt renders statement rows compactly: "6510 d2 h=false 45.0000".
func stmt(rows []StatementRow) []string {
var out []string
for _, r := range rows {
out = append(out, fmt.Sprintf("%s d%d h=%v %s", r.Account.Code, r.Account.Depth, r.Header, r.Amount))
}
return out
}

movements_test.go (package reporting, internal test):

func mv(t *testing.T, ctx context.Context, tx pgx.Tx, s Scope, from, to string, excludeClosing bool, types ...string) map[uuid.UUID]movement {
t.Helper()
cv, err := loadChart(ctx, tx)
require.NoError(t, err)
rs, err := resolveScope(ctx, tx, cv, s)
require.NoError(t, err)
q := movementQuery{To: reportingtest.Date(to), ExcludeClosing: excludeClosing, Scope: rs}
if len(types) > 0 {
q.Types = types
}
if from != "" {
f := reportingtest.Date(from)
q.From = &f
}
out, err := movements(ctx, tx, q)
require.NoError(t, err)
return out
}
func requireMove(t *testing.T, m map[uuid.UUID]movement, id uuid.UUID, dr, cr string) {
t.Helper()
require.Contains(t, m, id)
require.Equal(t, dr, m[id].Debit.String(), "debit")
require.Equal(t, cr, m[id].Credit.String(), "credit")
}
// TestMovementsSumBaseAmounts: E9 is USD 100, and Travel's movement is its
// base 420 plus E3's 1200 — never 100 + 1200.
func TestMovementsSumBaseAmounts(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
m := mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false)
requireMove(t, m, g.Books.Chart.Bank.ID, "15099.9900", "2264.9900")
requireMove(t, m, g.Books.Chart.Travel.ID, "1620.0000", "0.0000")
requireMove(t, m, g.Stationery.ID, "144.9900", "99.9900")
})
}
// TestTheClosingEntryIsExcludedOnlyWhenAsked: PRESENCE — with it, revenue
// moved on both sides; ABSENCE — without it, only the sale.
func TestTheClosingEntryIsExcludedOnlyWhenAsked(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
requireMove(t, mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false), g.Books.Chart.Revenue.ID, "5000.0000", "5000.0000")
requireMove(t, mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", true), g.Books.Chart.Revenue.ID, "0.0000", "5000.0000")
})
}
func TestMovementsWithoutAFromStartAtTheFirstEntry(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
m := mv(t, ctx, tx, Scope{}, "", "2026-03-31", false)
requireMove(t, m, g.Books.Chart.Bank.ID, "15000.0000", "1700.0000")
require.NotContains(t, m, g.Stationery.ID, "E6 is dated in April")
})
}
func TestMovementsByType(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
m := mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false, chart.TypeIncome)
require.Len(t, m, 1)
requireMove(t, m, g.Books.Chart.Revenue.ID, "0.0000", "5000.0000")
})
}
func TestMovementsByAccountSubtree(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
// PRESENCE: unfiltered, Bank moves.
require.Contains(t, mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false), g.Books.Chart.Bank.ID)
m := mv(t, ctx, tx, Scope{AccountID: &g.Office.ID}, "2026-01-01", "2026-12-31", false)
require.Len(t, m, 1, "only Stationery lies under Office")
requireMove(t, m, g.Stationery.ID, "144.9900", "99.9900")
})
}
// TestMovementsByAnalyticShareUseTheStoredShare: amounts are the stored
// distribution base amounts (Ruling R8), on the line's side, and an analytic
// account's descendants count.
func TestMovementsByAnalyticShareUseTheStoredShare(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
ax := g.Books.Axes
var child analytic.Account
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
child, err = analytic.CreateAccount(ctx, tx, e.Clock, analytic.AccountInput{
AxisID: ax.CostCentre.ID, ParentID: &ax.HQ.ID, Code: "HQ-KL", Name: "Headquarters KL"})
return err
})
g.Post(t, "", "2026-06-01", "", reportingtest.Dr(g.Books.Chart.Meals, "10", reportingtest.Share(child, "100")),
reportingtest.Cr(g.Books.Chart.Bank, "10"))
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
hq := mv(t, ctx, tx, Scope{AnalyticAccountIDs: []uuid.UUID{ax.HQ.ID}}, "2026-01-01", "2026-12-31", false)
requireMove(t, hq, g.Books.Chart.Revenue.ID, "0.0000", "3000.0000")
requireMove(t, hq, g.Books.Chart.Travel.ID, "1410.0000", "0.0000")
requireMove(t, hq, g.Books.Chart.Meals.ID, "10.0000", "0.0000") // the child's line
require.NotContains(t, hq, g.Books.Chart.Bank.ID, "Bank lines carry no distribution")
both := mv(t, ctx, tx, Scope{AnalyticAccountIDs: []uuid.UUID{ax.HQ.ID, ax.Ops.ID}}, "2026-01-01", "2026-12-31", false)
requireMove(t, both, g.Books.Chart.Revenue.ID, "0.0000", "5000.0000")
requireMove(t, both, g.Books.Chart.Travel.ID, "1620.0000", "0.0000")
})
}
func TestResolveScopeRefusals(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
ax := g.Books.Axes
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
cv, err := loadChart(ctx, tx)
require.NoError(t, err)
// PRESENCE: two accounts of one axis resolve.
_, err = resolveScope(ctx, tx, cv, Scope{AnalyticAccountIDs: []uuid.UUID{ax.HQ.ID, ax.Ops.ID}})
require.NoError(t, err)
_, err = resolveScope(ctx, tx, cv, Scope{AnalyticAccountIDs: []uuid.UUID{ax.HQ.ID, ax.ProjA.ID}})
require.ErrorIs(t, err, ErrAnalyticAxesMixed)
_, err = resolveScope(ctx, tx, cv, Scope{AnalyticAccountIDs: []uuid.UUID{uuid.New()}})
require.ErrorIs(t, err, ErrAnalyticAccountNotFound)
missing := uuid.New()
_, err = resolveScope(ctx, tx, cv, Scope{AccountID: &missing})
require.ErrorIs(t, err, ErrAccountNotFound)
require.NoError(t, CheckScope(ctx, tx, Scope{AccountID: &g.Office.ID}))
require.ErrorIs(t, CheckScope(ctx, tx, Scope{AccountID: &missing}), ErrAccountNotFound)
})
}
// TestMovementsAreTheCallingOrgsOnly: PRESENCE — the other org's sale is
// visible under that org; ABSENCE — not under ours.
func TestMovementsAreTheCallingOrgsOnly(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
other := e.Another(t, "Other Org")
og := reportingtest.Seed(t, other, reportingtest.Options{})
og.Post(t, "", "2026-06-01", "", reportingtest.Dr(og.Books.Chart.Bank, "777"), reportingtest.Cr(og.Books.Chart.Revenue, "777"))
require.NoError(t, other.DB.InReadOnlyTx(other.Ctx, func(ctx context.Context, tx pgx.Tx) error {
requireMove(t, mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false), og.Books.Chart.Revenue.ID, "0.0000", "5777.0000")
return nil
}))
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
m := mv(t, ctx, tx, Scope{}, "2026-01-01", "2026-12-31", false)
requireMove(t, m, g.Books.Chart.Revenue.ID, "0.0000", "5000.0000")
require.NotContains(t, m, og.Books.Chart.Revenue.ID)
})
}
func TestProvisionalFollowsOpenPeriods(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
from := reportingtest.Date("2026-01-01")
p, err := provisional(ctx, tx, &from, reportingtest.Date("2026-12-31"))
require.NoError(t, err)
require.False(t, p, "every FY2026 period is locked")
from = reportingtest.Date("2027-01-01")
p, err = provisional(ctx, tx, &from, reportingtest.Date("2027-01-31"))
require.NoError(t, err)
require.True(t, p, "FY2027 is open")
p, err = provisional(ctx, tx, nil, reportingtest.Date("2026-12-31"))
require.NoError(t, err)
require.False(t, p)
})
}

tree_test.go (pure; build a chartView from hand-made chart.Account values with newChartView(accounts)):

func TestRollupOrdersSiblingsByCodeAndSumsHeaders(t *testing.T) {
root := chart.Account{ID: uuid.New(), Code: "6000", Name: "Expenses", Type: chart.TypeExpense}
b := chart.Account{ID: uuid.New(), ParentID: &root.ID, Code: "6110", Name: "Meals", Type: chart.TypeExpense, IsPostable: true, Depth: 1}
a := chart.Account{ID: uuid.New(), ParentID: &root.ID, Code: "6100", Name: "Travel", Type: chart.TypeExpense, IsPostable: true, Depth: 1}
z := chart.Account{ID: uuid.New(), ParentID: &root.ID, Code: "6900", Name: "FX", Type: chart.TypeExpense, IsPostable: true, Depth: 1}
cv := newChartView([]chart.Account{root, b, a, z}) // deliberately not in code order
rows, total := cv.rollup(map[uuid.UUID]money.Amount{
a.ID: money.MustParse("10"), b.ID: money.MustParse("2.5"), z.ID: money.Zero()}, chart.TypeExpense)
require.Equal(t, "12.5000", total.String())
var got []string
for _, r := range rows {
got = append(got, fmt.Sprintf("%s %v %s", r.Account.Code, r.Header, r.Amount))
}
require.Equal(t, []string{"6000 true 12.5000", "6100 false 10.0000", "6110 false 2.5000"}, got,
"siblings by code; the zero FX row omitted")
}
func TestRollupOmitsAZeroSubtree(t *testing.T) {
root := chart.Account{ID: uuid.New(), Code: "6000", Type: chart.TypeExpense}
leaf := chart.Account{ID: uuid.New(), ParentID: &root.ID, Code: "6100", Type: chart.TypeExpense, IsPostable: true, Depth: 1}
rows, total := newChartView([]chart.Account{root, leaf}).rollup(map[uuid.UUID]money.Amount{}, chart.TypeExpense)
require.Empty(t, rows)
require.True(t, total.IsZero())
}

readonly_guard_test.go:

// TestReportingNeverWrites: the query layer is read-only by construction
// (Global Constraints). Every non-test .go file under
// internal/platform/reporting — except reporting/export, which owns
// report_export — is scanned for a write statement.
var writeSQL = regexp.MustCompile(`(?i)\b(INSERT\s+INTO|UPDATE\s+[a-z_]+\s+SET|DELETE\s+FROM)\b`)
func TestReportingNeverWrites(t *testing.T) {
require.True(t, writeSQL.MatchString("INSERT INTO report_export"), "the pattern must match a real write")
var scanned int
var offenders []string
require.NoError(t, filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "export" {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
scanned++
if writeSQL.Match(src) {
offenders = append(offenders, path)
}
return nil
}))
require.Greater(t, scanned, 3, "the walk reached no source files")
require.Empty(t, offenders, "reports never write (Ruling R3/R4): %v", offenders)
}
  • Step 6: Run and watch fail. go test ./internal/platform/reporting/ -count=1 → FAIL (undefined).

  • Step 7: Implement. doc.go: package comment — spec §10.7’s one query layer; figures only from posted journal_line base amounts (Ruling R3); every exported function takes a pgx.Tx its caller opened with db.InReadOnlyTx (R4); the year-end-close semantics of R5. errors.go: the five sentinels above.

    scope.go:

type Range struct{ From, To time.Time }
func (r Range) Validate() error {
if r.From.IsZero() || r.To.IsZero() || r.From.After(r.To) {
return ErrInvalidRange
}
return nil
}
type Scope struct {
AccountID *uuid.UUID
AnalyticAccountIDs []uuid.UUID
}
func (s Scope) Filtered() bool { return s.AccountID != nil || len(s.AnalyticAccountIDs) > 0 }
type AccountRef struct {
ID uuid.UUID
ParentID *uuid.UUID
Code string
Name string
Type string
Depth int
Postable bool
}
type chartView struct {
byID map[uuid.UUID]chart.Account
children map[uuid.UUID][]uuid.UUID // uuid.Nil: roots; each list sorted by code
}
func newChartView(accounts []chart.Account) chartView {
cv := chartView{byID: make(map[uuid.UUID]chart.Account, len(accounts)), children: map[uuid.UUID][]uuid.UUID{}}
for _, a := range accounts {
cv.byID[a.ID] = a
parent := uuid.Nil
if a.ParentID != nil {
parent = *a.ParentID
}
cv.children[parent] = append(cv.children[parent], a.ID)
}
for _, ids := range cv.children {
sort.Slice(ids, func(i, j int) bool { return cv.byID[ids[i]].Code < cv.byID[ids[j]].Code })
}
return cv
}
func loadChart(ctx context.Context, tx pgx.Tx) (chartView, error) {
accounts, err := chart.ListAccounts(ctx, tx)
if err != nil {
return chartView{}, fmt.Errorf("reporting: chart: %w", err)
}
return newChartView(accounts), nil
}
func (cv chartView) ref(id uuid.UUID) AccountRef {
a := cv.byID[id]
return AccountRef{ID: a.ID, ParentID: a.ParentID, Code: a.Code, Name: a.Name, Type: a.Type, Depth: a.Depth, Postable: a.IsPostable}
}
// postableByCode is every postable account, ordered by code (the TB's order).
func (cv chartView) postableByCode() []chart.Account {
var out []chart.Account
for _, a := range cv.byID {
if a.IsPostable {
out = append(out, a)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
return out
}
type resolvedScope struct {
pathPrefix string
analyticIDs []uuid.UUID
}
func (r resolvedScope) analytic() bool { return len(r.analyticIDs) > 0 }
const analyticAccountsSQL = `
SELECT aa.id, aa.axis_id FROM analytic_account aa WHERE aa.org_id = $1 AND aa.id = ANY($2)`
const axisTreeSQL = `
SELECT aa.id, aa.parent_id FROM analytic_account aa WHERE aa.org_id = $1 AND aa.axis_id = $2`
func resolveScope(ctx context.Context, tx pgx.Tx, cv chartView, s Scope) (resolvedScope, error) {
var out resolvedScope
if s.AccountID != nil {
a, ok := cv.byID[*s.AccountID]
if !ok {
return resolvedScope{}, ErrAccountNotFound
}
out.pathPrefix = a.Path
}
if len(s.AnalyticAccountIDs) == 0 {
return out, nil
}
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return resolvedScope{}, fmt.Errorf("reporting: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, analyticAccountsSQL, orgID, s.AnalyticAccountIDs)
if err != nil {
return resolvedScope{}, fmt.Errorf("reporting: analytic accounts: %w", err)
}
axes := map[uuid.UUID]bool{}
found := map[uuid.UUID]bool{}
for rows.Next() {
var id, axis uuid.UUID
if err := rows.Scan(&id, &axis); err != nil {
rows.Close()
return resolvedScope{}, fmt.Errorf("reporting: analytic accounts: %w", err)
}
found[id], axes[axis] = true, true
}
rows.Close()
if err := rows.Err(); err != nil {
return resolvedScope{}, fmt.Errorf("reporting: analytic accounts: %w", err)
}
for _, id := range s.AnalyticAccountIDs {
if !found[id] {
return resolvedScope{}, ErrAnalyticAccountNotFound
}
}
if len(axes) != 1 {
return resolvedScope{}, ErrAnalyticAxesMixed
}
var axis uuid.UUID
for a := range axes {
axis = a
}
// Descendants on that axis: the axis's own tree, walked in Go (small).
rows, err = tx.Query(ctx, axisTreeSQL, orgID, axis)
if err != nil {
return resolvedScope{}, fmt.Errorf("reporting: analytic tree: %w", err)
}
kids := map[uuid.UUID][]uuid.UUID{}
for rows.Next() {
var id uuid.UUID
var parent *uuid.UUID
if err := rows.Scan(&id, &parent); err != nil {
rows.Close()
return resolvedScope{}, fmt.Errorf("reporting: analytic tree: %w", err)
}
if parent != nil {
kids[*parent] = append(kids[*parent], id)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return resolvedScope{}, fmt.Errorf("reporting: analytic tree: %w", err)
}
seen := map[uuid.UUID]bool{}
stack := append([]uuid.UUID(nil), s.AnalyticAccountIDs...)
for len(stack) > 0 {
id := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if seen[id] {
continue
}
seen[id] = true
out.analyticIDs = append(out.analyticIDs, id)
stack = append(stack, kids[id]...)
}
return out, nil
}
func CheckScope(ctx context.Context, tx pgx.Tx, s Scope) error {
cv, err := loadChart(ctx, tx)
if err != nil {
return err
}
_, err = resolveScope(ctx, tx, cv, s)
return err
}
const provisionalSQL = `
SELECT EXISTS (SELECT 1 FROM fiscal_period p
WHERE p.org_id = $1 AND p.status = 'open'
AND p.starts_on <= $3 AND ($2::date IS NULL OR p.ends_on >= $2))`
func provisional(ctx context.Context, tx pgx.Tx, from *time.Time, to time.Time) (bool, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return false, fmt.Errorf("reporting: %w", tenancy.ErrNoOrg)
}
var p bool
if err := tx.QueryRow(ctx, provisionalSQL, orgID, from, to).Scan(&p); err != nil {
return false, fmt.Errorf("reporting: provisional: %w", err)
}
return p, nil
}
func baseCurrency(ctx context.Context, tx pgx.Tx) (string, error) {
p, err := company.Get(ctx, tx)
if err != nil {
return "", fmt.Errorf("reporting: company profile: %w", err)
}
return p.BaseCurrency, nil
}
func dayBefore(d time.Time) time.Time { return d.AddDate(0, 0, -1) }

movements.go:

type movement struct{ Debit, Credit money.Amount }
type movementQuery struct {
From *time.Time // nil: from the first entry
To time.Time
ExcludeClosing bool // leave out year_end_close entries (Ruling R5)
Types []string // nil: every account type
Scope resolvedScope
}
// movementsSQL: base debit and credit per account over posted entries.
const movementsSQL = `
SELECT l.account_id, coalesce(sum(l.base_debit), 0), coalesce(sum(l.base_credit), 0)
FROM journal_line l
JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id
WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1
AND ($2::date IS NULL OR e.entry_date >= $2) AND e.entry_date <= $3
AND NOT ($4::boolean AND e.semantic = 'year_end_close')
AND ($5::text[] IS NULL OR a.type = ANY($5))
AND ($6::text = '' OR a.path LIKE $6 || '%')
GROUP BY l.account_id`
// analyticMovementsSQL: the same, but each line counts only its stored
// share on the filtered analytic accounts (Ruling R8), on the line's side.
const analyticMovementsSQL = `
SELECT l.account_id,
coalesce(sum(CASE WHEN l.base_debit > 0 THEN s.share ELSE 0 END), 0),
coalesce(sum(CASE WHEN l.base_credit > 0 THEN s.share ELSE 0 END), 0)
FROM (SELECT d.journal_line_id, sum(d.base_amount) AS share
FROM analytic_distribution d
WHERE d.org_id = $1 AND d.analytic_account_id = ANY($7)
GROUP BY d.journal_line_id) s
JOIN journal_line l ON l.org_id = $1 AND l.id = s.journal_line_id
JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id
WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1
AND ($2::date IS NULL OR e.entry_date >= $2) AND e.entry_date <= $3
AND NOT ($4::boolean AND e.semantic = 'year_end_close')
AND ($5::text[] IS NULL OR a.type = ANY($5))
AND ($6::text = '' OR a.path LIKE $6 || '%')
GROUP BY l.account_id`
func movements(ctx context.Context, tx pgx.Tx, q movementQuery) (map[uuid.UUID]movement, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("reporting: %w", tenancy.ErrNoOrg)
}
sql, args := movementsSQL, []any{orgID, q.From, q.To, q.ExcludeClosing, q.Types, q.Scope.pathPrefix}
if q.Scope.analytic() {
sql, args = analyticMovementsSQL, append(args, q.Scope.analyticIDs)
}
rows, err := tx.Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("reporting: movements: %w", err)
}
defer rows.Close()
out := map[uuid.UUID]movement{}
for rows.Next() {
var id uuid.UUID
var m movement
if err := rows.Scan(&id, &m.Debit, &m.Credit); err != nil {
return nil, fmt.Errorf("reporting: movements: %w", err)
}
out[id] = m
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("reporting: movements: %w", err)
}
return out, nil
}
// lookup is ms[id], or zeros when the account did not move. money.Amount
// wraps a decimal holding a pointer, so a missing map value (the zero
// struct) must never be compared or added as if it were zero.
func lookup(ms map[uuid.UUID]movement, id uuid.UUID) movement {
if m, ok := ms[id]; ok {
return m
}
return movement{Debit: money.Zero(), Credit: money.Zero()}
}
func (m movement) net() money.Amount { return m.Debit.Sub(m.Credit) }

(q.Types nil must reach Postgres as SQL NULL: pgx v5 encodes a nil []string as NULL. TestMovementsWithoutAFromStartAtTheFirstEntry and TestMovementsByType cover both branches.) The path LIKE is safe: account.path is UUIDs and slashes from the database, never request text.

tree.go:

type StatementRow struct {
Account AccountRef
Header bool
Amount money.Amount
}
// rollup walks the roots of type accType in code order, depth first, each
// node's amount being its own leaf amount plus its descendants'. Zero nodes
// (and so wholly-zero subtrees) are omitted (Ruling R10). total is the sum
// over the roots.
func (cv chartView) rollup(leaf map[uuid.UUID]money.Amount, accType string) ([]StatementRow, money.Amount) {
var rows []StatementRow
var walk func(id uuid.UUID) money.Amount
walk = func(id uuid.UUID) money.Amount {
at := len(rows)
rows = append(rows, StatementRow{}) // placeholder: the header precedes its children
sum := money.Zero()
if v, ok := leaf[id]; ok {
sum = sum.Add(v)
}
for _, c := range cv.children[id] {
sum = sum.Add(walk(c))
}
if sum.IsZero() {
rows = rows[:at] // drop this node and anything its zero children left (none: they drop themselves)
return sum
}
a := cv.byID[id]
rows[at] = StatementRow{Account: cv.ref(id), Header: !a.IsPostable, Amount: sum}
return sum
}
total := money.Zero()
for _, root := range cv.children[uuid.Nil] {
if cv.byID[root].Type == accType {
total = total.Add(walk(root))
}
}
return rows, total
}

Note on rows = rows[:at] for a zero node with non-zero children that cancel: a header whose children sum to zero drops the whole subtree, including non-zero children that cancel each other. That is intended: a subtree netting to zero is shown as nothing in a statement. Record this in the doc comment; TestRollupOmitsAZeroSubtree covers the all-zero case.

  • Step 8: Run. go test ./internal/platform/reporting/... ./internal/kernel/layering/ -count=1 → PASS, including TestEveryTenantQueryCarriesAnExplicitOrgPredicate over the new SQL constants.

  • Step 9: Sabotage once. (a) Remove AND a.org_id = $1 from movementsSQL → the tenant-predicate guard red. (b) In analyticMovementsSQL replace s.share with l.base_debit in the debit CASE → TestMovementsByAnalyticShareUseTheStoredShare red. (c) Add a string "DELETE FROM account" to tree.go → TestReportingNeverWrites red. Restore; quote all three.

  • Step 10: Commit

Terminal window
git add internal/platform/reporting
git commit -m "Add the reporting query layer and the golden books
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/reporting/trialbalance.go, internal/platform/reporting/profitandloss.go
  • Test: internal/platform/reporting/trialbalance_test.go, internal/platform/reporting/profitandloss_test.go

Interfaces:

  • Consumes: Task 6’s Range, Scope, AccountRef, StatementRow, loadChart, resolveScope, movements, lookup, movement.net, provisional, baseCurrency, dayBefore, chartView.postableByCode, chartView.rollup, reportingtest.Seed.

  • Produces:

    type TrialBalanceParams struct {
    Range Range
    AccountID *uuid.UUID // subtree (Ruling R9)
    ExcludeClosing bool // pre-closing TB (Ruling R5)
    }
    type TrialBalanceRow struct {
    Account AccountRef
    OpeningDebit, OpeningCredit money.Amount // net, one side non-zero
    Debit, Credit money.Amount // movement in the range
    ClosingDebit, ClosingCredit money.Amount // net, one side non-zero
    }
    type TrialBalanceTotals struct {
    OpeningDebit, OpeningCredit, Debit, Credit, ClosingDebit, ClosingCredit money.Amount
    }
    type TrialBalanceReport struct {
    Params TrialBalanceParams
    Currency string
    Provisional bool
    Filtered bool
    Rows []TrialBalanceRow
    Totals TrialBalanceTotals
    Balanced bool
    }
    func TrialBalance(ctx context.Context, tx pgx.Tx, p TrialBalanceParams) (TrialBalanceReport, error)
    type ProfitAndLossParams struct {
    Range Range
    Scope Scope
    }
    type ProfitAndLossReport struct {
    Params ProfitAndLossParams
    Currency string
    Provisional, Filtered bool
    Income, Expenses []StatementRow
    TotalIncome, TotalExpenses, NetProfit money.Amount
    }
    func ProfitAndLoss(ctx context.Context, tx pgx.Tx, p ProfitAndLossParams) (ProfitAndLossReport, error)
  • Step 1: Write the failing tests. trialbalance_test.go (reuse readTx from Task 6’s test file; a helper renders rows compactly):

func tbLines(r TrialBalanceReport) []string {
var out []string
for _, row := range r.Rows {
out = append(out, fmt.Sprintf("%s %s/%s %s/%s %s/%s", row.Account.Code,
row.OpeningDebit, row.OpeningCredit, row.Debit, row.Credit, row.ClosingDebit, row.ClosingCredit))
}
return out
}
func tb(t *testing.T, e *accountingtest.Env, p TrialBalanceParams) TrialBalanceReport {
t.Helper()
var r TrialBalanceReport
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
var err error
r, err = TrialBalance(ctx, tx, p)
require.NoError(t, err)
})
return r
}
// TestTheTrialBalanceIsPostClosingByDefault: Ruling R5 — the closing entry
// is part of the ledger, so FY2026's P&L accounts close at zero and retained
// earnings holds the result.
func TestTheTrialBalanceIsPostClosingByDefault(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := tb(t, e, TrialBalanceParams{Range: fy("2026")})
require.Equal(t, []string{
"1510 0.0000/0.0000 500.0000/300.5000 199.5000/0.0000",
"1520 0.0000/0.0000 15099.9900/2264.9900 12835.0000/0.0000",
"3100 0.0000/0.0000 0.0000/10000.0000 0.0000/10000.0000",
"3200 0.0000/0.0000 0.0000/3034.5000 0.0000/3034.5000",
"4100 0.0000/0.0000 5000.0000/5000.0000 0.0000/0.0000",
"6100 0.0000/0.0000 1620.0000/1620.0000 0.0000/0.0000",
"6110 0.0000/0.0000 300.5000/300.5000 0.0000/0.0000",
"6510 0.0000/0.0000 144.9900/144.9900 0.0000/0.0000",
}, tbLines(r))
require.Equal(t, "22665.4800", r.Totals.Debit.String())
require.Equal(t, "22665.4800", r.Totals.Credit.String())
require.Equal(t, "13034.5000", r.Totals.ClosingDebit.String())
require.True(t, r.Balanced)
require.False(t, r.Provisional)
require.Equal(t, "MYR", r.Currency)
}
func TestExcludeClosingGivesThePreClosingTrialBalance(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := tb(t, e, TrialBalanceParams{Range: fy("2026"), ExcludeClosing: true})
require.Equal(t, []string{
"1510 0.0000/0.0000 500.0000/300.5000 199.5000/0.0000",
"1520 0.0000/0.0000 15099.9900/2264.9900 12835.0000/0.0000",
"3100 0.0000/0.0000 0.0000/10000.0000 0.0000/10000.0000",
"4100 0.0000/0.0000 0.0000/5000.0000 0.0000/5000.0000",
"6100 0.0000/0.0000 1620.0000/0.0000 1620.0000/0.0000",
"6110 0.0000/0.0000 300.5000/0.0000 300.5000/0.0000",
"6510 0.0000/0.0000 144.9900/99.9900 45.0000/0.0000",
}, tbLines(r), "retained earnings moved only in the closing entry, so its row is gone")
require.Equal(t, "17665.4800", r.Totals.Debit.String())
require.True(t, r.Balanced)
}
// TestOpeningsCarryForwardAcrossTheClose: FY2027 opens with the balance
// sheet only (Ruling R7); the P&L accounts open at zero because the close
// zeroed them.
func TestOpeningsCarryForwardAcrossTheClose(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := tb(t, e, TrialBalanceParams{Range: fy("2027")})
require.Equal(t, []string{
"1510 199.5000/0.0000 0.0000/0.0000 199.5000/0.0000",
"1520 12835.0000/0.0000 800.0000/200.0000 13435.0000/0.0000",
"3100 0.0000/10000.0000 0.0000/0.0000 0.0000/10000.0000",
"3200 0.0000/3034.5000 0.0000/0.0000 0.0000/3034.5000",
"4100 0.0000/0.0000 0.0000/800.0000 0.0000/800.0000",
"6100 0.0000/0.0000 200.0000/0.0000 200.0000/0.0000",
}, tbLines(r))
require.True(t, r.Balanced)
require.True(t, r.Provisional, "FY2027 is open")
}
func TestAnUnclosedYearCarriesItsResultInTheOpenings(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{})
r := tb(t, e, TrialBalanceParams{Range: fy("2027")})
require.Contains(t, tbLines(r), "4100 0.0000/5000.0000 0.0000/800.0000 0.0000/5800.0000")
require.True(t, r.Balanced)
}
func TestASubtreeTrialBalanceIsMarkedFiltered(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
r := tb(t, e, TrialBalanceParams{Range: fy("2026"), AccountID: &g.Office.ID})
require.Equal(t, []string{"6510 0.0000/0.0000 144.9900/99.9900 45.0000/0.0000"}, tbLines(r))
require.True(t, r.Filtered)
}
func TestTheTrialBalanceRefusesABackwardsRange(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{})
tb(t, e, TrialBalanceParams{Range: fy("2026")}) // PRESENCE: a valid range works
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
_, err := TrialBalance(ctx, tx, TrialBalanceParams{Range: Range{From: reportingtest.Date("2026-02-01"), To: reportingtest.Date("2026-01-01")}})
require.ErrorIs(t, err, ErrInvalidRange)
})
}

profitandloss_test.go:

func pl(t *testing.T, e *accountingtest.Env, p ProfitAndLossParams) ProfitAndLossReport {
t.Helper()
var r ProfitAndLossReport
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
var err error
r, err = ProfitAndLoss(ctx, tx, p)
require.NoError(t, err)
})
return r
}
// TestAClosedYearStillShowsItsResult is Ruling R5's reason to exist. The TB
// proves the closing entry is there (PRESENCE); the P&L still reports the
// year's result.
func TestAClosedYearStillShowsItsResult(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
require.Contains(t, tbLines(tb(t, e, TrialBalanceParams{Range: fy("2026")})),
"4100 0.0000/0.0000 5000.0000/5000.0000 0.0000/0.0000", "the closing entry zeroed revenue")
r := pl(t, e, ProfitAndLossParams{Range: fy("2026")})
require.Equal(t, []string{"4000 d0 h=true 5000.0000", "4100 d1 h=false 5000.0000"}, stmt(r.Income))
require.Equal(t, []string{
"6000 d0 h=true 1965.5000",
"6100 d1 h=false 1620.0000",
"6110 d1 h=false 300.5000",
"6500 d1 h=true 45.0000",
"6510 d2 h=false 45.0000",
}, stmt(r.Expenses))
require.Equal(t, "5000.0000", r.TotalIncome.String())
require.Equal(t, "1965.5000", r.TotalExpenses.String())
require.Equal(t, "3034.5000", r.NetProfit.String())
require.False(t, r.Provisional)
}
func TestTheProfitIsTheSameBeforeAndAfterTheClose(t *testing.T) {
open, closed := accountingtest.New(t), accountingtest.New(t)
reportingtest.Seed(t, open, reportingtest.Options{})
reportingtest.Seed(t, closed, reportingtest.Options{CloseFY2026: true})
a, b := pl(t, open, ProfitAndLossParams{Range: fy("2026")}), pl(t, closed, ProfitAndLossParams{Range: fy("2026")})
require.Equal(t, stmt(a.Income), stmt(b.Income))
require.Equal(t, stmt(a.Expenses), stmt(b.Expenses))
require.Equal(t, a.NetProfit, b.NetProfit)
require.True(t, a.Provisional, "the unclosed year's periods are open")
}
func TestAProfitAndLossMaySpanTwoYears(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := pl(t, e, ProfitAndLossParams{Range: Range{From: reportingtest.Date("2026-07-01"), To: reportingtest.Date("2027-01-31")}})
require.Equal(t, "800.0000", r.TotalIncome.String())
require.Equal(t, "620.0000", r.TotalExpenses.String())
require.Equal(t, "180.0000", r.NetProfit.String())
}
// TestAnAnalyticProfitAndLossUsesStoredShares (Ruling R8).
func TestAnAnalyticProfitAndLossUsesStoredShares(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
ax := g.Books.Axes
for _, c := range []struct {
ids []uuid.UUID
income, expenses, net string
}{
{[]uuid.UUID{ax.HQ.ID}, "3000.0000", "1410.0000", "1590.0000"},
{[]uuid.UUID{ax.Ops.ID}, "2000.0000", "510.5000", "1489.5000"},
{[]uuid.UUID{ax.HQ.ID, ax.Ops.ID}, "5000.0000", "1920.5000", "3079.5000"}, // untagged stationery excluded
} {
r := pl(t, e, ProfitAndLossParams{Range: fy("2026"), Scope: Scope{AnalyticAccountIDs: c.ids}})
require.Equal(t, c.income, r.TotalIncome.String())
require.Equal(t, c.expenses, r.TotalExpenses.String())
require.Equal(t, c.net, r.NetProfit.String())
require.True(t, r.Filtered)
}
}
func TestASubtreeProfitAndLoss(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
r := pl(t, e, ProfitAndLossParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Office.ID}})
require.Empty(t, r.Income)
require.Equal(t, []string{"6500 d1 h=true 45.0000", "6510 d2 h=false 45.0000"}, stmt(r.Expenses),
"the subtree root is where the walk starts")
require.Equal(t, "-45.0000", r.NetProfit.String())
}

TestASubtreeProfitAndLoss needs rollup to start at the subtree root, not the chart roots: implement ProfitAndLoss so that when p.Scope.AccountID is set it rolls up from that account (see Step 3).

  • Step 2: Run and watch fail. go test ./internal/platform/reporting/ -run 'Trial|Profit|Closed|Close|Span|Analytic|Subtree' -count=1 → FAIL (undefined).

  • Step 3: Implement. First extend tree.go with a rooted variant (both reports share it):

// rollupFrom is rollup starting at one account rather than at the roots of
// a type — for a subtree-scoped statement (Ruling R9).
func (cv chartView) rollupFrom(root uuid.UUID, leaf map[uuid.UUID]money.Amount) ([]StatementRow, money.Amount) {
sub := chartView{byID: cv.byID, children: map[uuid.UUID][]uuid.UUID{uuid.Nil: {root}}}
for k, v := range cv.children {
if k != uuid.Nil {
sub.children[k] = v
}
}
return sub.rollup(leaf, cv.byID[root].Type)
}

trialbalance.go:

func side(net money.Amount) (debit, credit money.Amount) {
if net.IsNegative() {
return money.Zero(), net.Neg()
}
return net, money.Zero()
}
func TrialBalance(ctx context.Context, tx pgx.Tx, p TrialBalanceParams) (TrialBalanceReport, error) {
if err := p.Range.Validate(); err != nil {
return TrialBalanceReport{}, err
}
cv, err := loadChart(ctx, tx)
if err != nil {
return TrialBalanceReport{}, err
}
scope, err := resolveScope(ctx, tx, cv, Scope{AccountID: p.AccountID})
if err != nil {
return TrialBalanceReport{}, err
}
opening, err := movements(ctx, tx, movementQuery{To: dayBefore(p.Range.From), Scope: scope})
if err != nil {
return TrialBalanceReport{}, err
}
from := p.Range.From
period, err := movements(ctx, tx, movementQuery{From: &from, To: p.Range.To, ExcludeClosing: p.ExcludeClosing, Scope: scope})
if err != nil {
return TrialBalanceReport{}, err
}
r := TrialBalanceReport{Params: p, Filtered: p.AccountID != nil}
if r.Currency, err = baseCurrency(ctx, tx); err != nil {
return TrialBalanceReport{}, err
}
if r.Provisional, err = provisional(ctx, tx, &from, p.Range.To); err != nil {
return TrialBalanceReport{}, err
}
t := &r.Totals
*t = TrialBalanceTotals{money.Zero(), money.Zero(), money.Zero(), money.Zero(), money.Zero(), money.Zero()}
for _, a := range cv.postableByCode() {
m := lookup(period, a.ID)
openNet := lookup(opening, a.ID).net()
closeNet := openNet.Add(m.net())
if openNet.IsZero() && m.Debit.IsZero() && m.Credit.IsZero() && closeNet.IsZero() {
continue
}
row := TrialBalanceRow{Account: cv.ref(a.ID), Debit: m.Debit, Credit: m.Credit}
row.OpeningDebit, row.OpeningCredit = side(openNet)
row.ClosingDebit, row.ClosingCredit = side(closeNet)
r.Rows = append(r.Rows, row)
t.OpeningDebit, t.OpeningCredit = t.OpeningDebit.Add(row.OpeningDebit), t.OpeningCredit.Add(row.OpeningCredit)
t.Debit, t.Credit = t.Debit.Add(row.Debit), t.Credit.Add(row.Credit)
t.ClosingDebit, t.ClosingCredit = t.ClosingDebit.Add(row.ClosingDebit), t.ClosingCredit.Add(row.ClosingCredit)
}
r.Balanced = t.OpeningDebit.Cmp(t.OpeningCredit) == 0 && t.Debit.Cmp(t.Credit) == 0 && t.ClosingDebit.Cmp(t.ClosingCredit) == 0
return r, nil
}

profitandloss.go:

func ProfitAndLoss(ctx context.Context, tx pgx.Tx, p ProfitAndLossParams) (ProfitAndLossReport, error) {
if err := p.Range.Validate(); err != nil {
return ProfitAndLossReport{}, err
}
cv, err := loadChart(ctx, tx)
if err != nil {
return ProfitAndLossReport{}, err
}
scope, err := resolveScope(ctx, tx, cv, p.Scope)
if err != nil {
return ProfitAndLossReport{}, 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}, Scope: scope})
if err != nil {
return ProfitAndLossReport{}, err
}
// Presentation sign (Ruling R10): income credit-positive, expense debit-positive.
leaf := make(map[uuid.UUID]money.Amount, len(m))
for id, mv := range m {
if cv.byID[id].Type == chart.TypeIncome {
leaf[id] = mv.Credit.Sub(mv.Debit)
} else {
leaf[id] = mv.Debit.Sub(mv.Credit)
}
}
r := ProfitAndLossReport{Params: p, Filtered: p.Scope.Filtered()}
if p.Scope.AccountID != nil {
rows, total := cv.rollupFrom(*p.Scope.AccountID, leaf)
switch cv.byID[*p.Scope.AccountID].Type {
case chart.TypeIncome:
r.Income, r.TotalIncome, r.TotalExpenses = rows, total, money.Zero()
case chart.TypeExpense:
r.Expenses, r.TotalExpenses, r.TotalIncome = rows, total, money.Zero()
default: // a balance-sheet subtree has no P&L lines
r.TotalIncome, r.TotalExpenses = money.Zero(), money.Zero()
}
} else {
r.Income, r.TotalIncome = cv.rollup(leaf, chart.TypeIncome)
r.Expenses, r.TotalExpenses = cv.rollup(leaf, chart.TypeExpense)
}
r.NetProfit = r.TotalIncome.Sub(r.TotalExpenses)
if r.Currency, err = baseCurrency(ctx, tx); err != nil {
return ProfitAndLossReport{}, err
}
if r.Provisional, err = provisional(ctx, tx, &from, p.Range.To); err != nil {
return ProfitAndLossReport{}, err
}
return r, nil
}

Nil slices for Income/Expenses are fine here; the module marshals them as [] (Task 14).

  • Step 4: Run. go test ./internal/platform/reporting/ -count=1 → PASS.

  • Step 5: Sabotage once. Set ExcludeClosing: false in ProfitAndLoss → TestAClosedYearStillShowsItsResult red (net profit 0). Restore; quote it.

  • Step 6: Commit

Terminal window
git add internal/platform/reporting/trialbalance.go internal/platform/reporting/profitandloss.go \
internal/platform/reporting/tree.go internal/platform/reporting/*_test.go
git commit -m "Add the trial balance and the profit and loss, closed years included
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/reporting/balancesheet.go
  • Test: internal/platform/reporting/balancesheet_test.go

Interfaces:

  • Consumes: Task 6 (loadChart, movements, provisional, baseCurrency, dayBefore, rollup; test helpers readTx, stmt); fiscal.YearContaining, fiscal.ErrNotFound.

  • Produces:

    type BalanceSheetParams struct{ AsOf time.Time }
    type BalanceSheetReport struct {
    Params BalanceSheetParams
    Currency string
    Provisional bool
    Assets, Liabilities, Equity []StatementRow
    CurrentYearEarnings, PriorUnclosedEarnings money.Amount // Ruling R5
    TotalAssets, TotalLiabilities money.Amount
    TotalEquity money.Amount // equity accounts + both earnings lines
    Balanced bool // TotalAssets == TotalLiabilities + TotalEquity
    }
    func BalanceSheet(ctx context.Context, tx pgx.Tx, p BalanceSheetParams) (BalanceSheetReport, error)
  • Step 1: Write the failing tests — balancesheet_test.go:

func bs(t *testing.T, e *accountingtest.Env, asOf string) BalanceSheetReport {
t.Helper()
var r BalanceSheetReport
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
var err error
r, err = BalanceSheet(ctx, tx, BalanceSheetParams{AsOf: reportingtest.Date(asOf)})
require.NoError(t, err)
})
return r
}
func TestTheBalanceSheetAtAClosedYearEnd(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := bs(t, e, "2026-12-31")
require.Equal(t, []string{"1000 d0 h=true 13034.5000", "1510 d1 h=false 199.5000", "1520 d1 h=false 12835.0000"}, stmt(r.Assets))
require.Empty(t, r.Liabilities)
require.Equal(t, []string{"3000 d0 h=true 13034.5000", "3100 d1 h=false 10000.0000", "3200 d1 h=false 3034.5000"}, stmt(r.Equity))
require.Equal(t, "0.0000", r.CurrentYearEarnings.String(), "the close moved the year's result into retained earnings")
require.Equal(t, "0.0000", r.PriorUnclosedEarnings.String())
require.Equal(t, "13034.5000", r.TotalAssets.String())
require.Equal(t, "13034.5000", r.TotalEquity.String())
require.True(t, r.Balanced)
require.False(t, r.Provisional)
}
// TestMidYearEarningsAreCurrentYearEarnings: queried after the close, a
// mid-2026 balance sheet still shows 2026's earnings to date as earnings —
// the closing entry is dated 31 December and is not yet in effect on 30 June.
func TestMidYearEarningsAreCurrentYearEarnings(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := bs(t, e, "2026-06-30")
require.Equal(t, "13454.5000", r.TotalAssets.String())
require.Equal(t, "3454.5000", r.CurrentYearEarnings.String())
require.Equal(t, []string{"3000 d0 h=true 10000.0000", "3100 d1 h=false 10000.0000"}, stmt(r.Equity))
require.True(t, r.Balanced)
}
func TestTheBalanceSheetAfterTheClose(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
r := bs(t, e, "2027-01-31")
require.Equal(t, "13634.5000", r.TotalAssets.String())
require.Equal(t, "600.0000", r.CurrentYearEarnings.String())
require.Equal(t, "0.0000", r.PriorUnclosedEarnings.String())
require.Contains(t, stmt(r.Equity), "3200 d1 h=false 3034.5000")
require.True(t, r.Balanced)
require.True(t, r.Provisional)
}
// TestAnUnclosedPriorYearIsShownAsSuch: PRESENCE of the split — with FY2026
// unclosed, its result is prior unclosed earnings and retained earnings is
// empty; the sheet still balances.
func TestAnUnclosedPriorYearIsShownAsSuch(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{})
r := bs(t, e, "2027-01-31")
require.Equal(t, "600.0000", r.CurrentYearEarnings.String())
require.Equal(t, "3034.5000", r.PriorUnclosedEarnings.String())
require.NotContains(t, stmt(r.Equity), "3200 d1 h=false 3034.5000")
require.Equal(t, "13634.5000", r.TotalEquity.String())
require.True(t, r.Balanced)
}
func TestADateOutsideEveryFiscalYearIsAllPriorEarnings(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{})
r := bs(t, e, "2028-06-30") // no FY2028 exists
require.Equal(t, "0.0000", r.CurrentYearEarnings.String())
require.Equal(t, "3634.5000", r.PriorUnclosedEarnings.String())
require.True(t, r.Balanced)
}
  • Step 2: Run and watch fail. go test ./internal/platform/reporting/ -run Balance -count=1 → FAIL.

  • Step 3: Implement balancesheet.go:

func BalanceSheet(ctx context.Context, tx pgx.Tx, p BalanceSheetParams) (BalanceSheetReport, error) {
if p.AsOf.IsZero() {
return BalanceSheetReport{}, ErrInvalidRange
}
cv, err := loadChart(ctx, tx)
if err != nil {
return BalanceSheetReport{}, err
}
// Every entry through as_of, closing entries included (Ruling R5).
m, err := movements(ctx, tx, movementQuery{To: p.AsOf,
Types: []string{chart.TypeAsset, chart.TypeLiability, chart.TypeEquity}})
if err != nil {
return BalanceSheetReport{}, err
}
leaf := make(map[uuid.UUID]money.Amount, len(m))
for id, mv := range m {
if cv.byID[id].Type == chart.TypeAsset {
leaf[id] = mv.Debit.Sub(mv.Credit)
} else {
leaf[id] = mv.Credit.Sub(mv.Debit)
}
}
r := BalanceSheetReport{Params: p}
r.Assets, r.TotalAssets = cv.rollup(leaf, chart.TypeAsset)
r.Liabilities, r.TotalLiabilities = cv.rollup(leaf, chart.TypeLiability)
var equity money.Amount
r.Equity, equity = cv.rollup(leaf, chart.TypeEquity)
// Earnings not yet closed: split at the start of the fiscal year
// containing as_of; with no such year, all of it is prior.
r.CurrentYearEarnings, r.PriorUnclosedEarnings = money.Zero(), money.Zero()
priorTo := p.AsOf
y, err := fiscal.YearContaining(ctx, tx, p.AsOf)
switch {
case err == nil:
start := y.StartsOn
if r.CurrentYearEarnings, err = earnings(ctx, tx, &start, p.AsOf); err != nil {
return BalanceSheetReport{}, err
}
priorTo = dayBefore(y.StartsOn)
case !errors.Is(err, fiscal.ErrNotFound):
return BalanceSheetReport{}, fmt.Errorf("reporting: balance sheet: %w", err)
}
if r.PriorUnclosedEarnings, err = earnings(ctx, tx, nil, priorTo); err != nil {
return BalanceSheetReport{}, err
}
r.TotalEquity = equity.Add(r.CurrentYearEarnings).Add(r.PriorUnclosedEarnings)
r.Balanced = r.TotalAssets.Cmp(r.TotalLiabilities.Add(r.TotalEquity)) == 0
if r.Currency, err = baseCurrency(ctx, tx); err != nil {
return BalanceSheetReport{}, err
}
if r.Provisional, err = provisional(ctx, tx, nil, p.AsOf); err != nil {
return BalanceSheetReport{}, err
}
return r, nil
}
// earnings is income − expense (credit-positive) over entries dated in
// [from, to], closing entries included.
func earnings(ctx context.Context, tx pgx.Tx, from *time.Time, to time.Time) (money.Amount, error) {
m, err := movements(ctx, tx, movementQuery{From: from, To: to, Types: []string{chart.TypeIncome, chart.TypeExpense}})
if err != nil {
return money.Amount{}, err
}
sum := money.Zero()
for _, mv := range m {
sum = sum.Add(mv.Credit).Sub(mv.Debit)
}
return sum, nil
}
  • Step 4: Run. go test ./internal/platform/reporting/ -count=1 → PASS.

  • Step 5: Sabotage once. Pass ExcludeClosing: true to the asset/liability/equity movements → TestTheBalanceSheetAtAClosedYearEnd red (retained earnings missing, sheet still balances only by accident of the earnings line — read the failure). Restore; quote it.

  • Step 6: Commit

Terminal window
git add internal/platform/reporting/balancesheet.go internal/platform/reporting/balancesheet_test.go
git commit -m "Add the balance sheet, balanced across the year-end close
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 9: General-ledger detail — streamed, keyset-paged, analytic-aware

Section titled “Task 9: General-ledger detail — streamed, keyset-paged, analytic-aware”

Files:

  • Create: internal/platform/reporting/generalledger.go, internal/platform/reporting/cursor.go
  • Test: internal/platform/reporting/generalledger_test.go

Interfaces:

  • Consumes: Task 6 (loadChart, resolveScope, movements, lookup, movement.net, provisional, baseCurrency, dayBefore, chartView.ref).

  • Produces:

    const (
    DefaultGLPage = 500
    MaxGLPage = 2000
    )
    type GeneralLedgerParams struct {
    Range Range
    Scope Scope
    }
    // GLAccount opens one account's section: Opening is the debit-positive
    // balance just before the first line delivered for it.
    type GLAccount struct {
    Account AccountRef
    Opening money.Amount
    }
    type GLLine struct {
    AccountID uuid.UUID
    EntryID uuid.UUID
    EntryNo string
    EntryDate time.Time
    JournalCode string
    Semantic string
    Description, Memo string
    LineNo int
    Debit, Credit money.Amount // base, or the analytic share (Ruling R8)
    Balance money.Amount // running, debit-positive
    Currency string
    TxDebit, TxCredit *money.Amount // nil under an analytic filter (Ruling R11)
    SourceKind *string
    SourceID *uuid.UUID
    ReversalOfID *uuid.UUID
    }
    type GLVisitor interface {
    Account(GLAccount) error
    Line(GLLine) error
    }
    type GLCursor struct {
    Code string
    AccountID uuid.UUID
    EntryDate time.Time
    PostedSeq int64
    LineNo int
    }
    func (c GLCursor) Encode() string // base64url(JSON)
    func DecodeGLCursor(s string) (GLCursor, error) // ErrBadCursor
    type GLResult struct {
    Currency string
    Provisional, Filtered bool
    Next *GLCursor // nil: nothing after this page
    }
    // GeneralLedgerHeader is GLResult's Currency/Provisional/Filtered without
    // streaming (Next nil) — what an export writes before the first row.
    func GeneralLedgerHeader(ctx context.Context, tx pgx.Tx, p GeneralLedgerParams) (GLResult, error)
    // GeneralLedger streams lines after `after` (nil: from the start) to v.
    // limit 0 streams everything (exports); otherwise at most limit lines.
    func GeneralLedger(ctx context.Context, tx pgx.Tx, p GeneralLedgerParams, after *GLCursor, limit int, v GLVisitor) (GLResult, error)
    // CountGeneralLedger is how many lines GeneralLedger would stream with no limit.
    func CountGeneralLedger(ctx context.Context, tx pgx.Tx, p GeneralLedgerParams) (int64, error)
  • Step 1: Write the failing tests — generalledger_test.go:

// collect is a GLVisitor that renders what it is given.
type collect struct{ out []string }
func (c *collect) Account(a GLAccount) error {
c.out = append(c.out, fmt.Sprintf("[%s open %s]", a.Account.Code, a.Opening))
return nil
}
func (c *collect) Line(l GLLine) error {
c.out = append(c.out, fmt.Sprintf("%s %s %s/%s = %s", l.EntryDate.Format("01-02"), l.Semantic, l.Debit, l.Credit, l.Balance))
return nil
}
func gl(t *testing.T, e *accountingtest.Env, p GeneralLedgerParams, after *GLCursor, limit int) ([]string, GLResult) {
t.Helper()
c := &collect{}
var res GLResult
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
var err error
res, err = GeneralLedger(ctx, tx, p, after, limit, c)
require.NoError(t, err)
})
return c.out, res
}
func TestTheBankLedgerForFY2026(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
out, res := gl(t, e, GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Books.Chart.Bank.ID}}, nil, 0)
require.Equal(t, []string{
"[1520 open 0.0000]",
"01-02 manual 10000.0000/0.0000 = 10000.0000",
"02-10 manual 5000.0000/0.0000 = 15000.0000",
"03-15 manual 0.0000/1200.0000 = 13800.0000",
"03-18 manual 0.0000/500.0000 = 13300.0000",
"04-05 manual 0.0000/99.9900 = 13200.0100",
"04-30 reversal 99.9900/0.0000 = 13300.0000",
"05-10 manual 0.0000/45.0000 = 13255.0000",
"09-18 manual 0.0000/420.0000 = 12835.0000",
}, out)
require.Nil(t, res.Next)
require.True(t, res.Filtered)
}
// TestPagesCarryTheirOpeningBalance: three pages of three, each opening
// where the last one closed.
func TestPagesCarryTheirOpeningBalance(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
p := GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Books.Chart.Bank.ID}}
page1, res := gl(t, e, p, nil, 3)
require.Equal(t, "[1520 open 0.0000]", page1[0])
require.Len(t, page1, 4)
require.NotNil(t, res.Next)
cur, err := DecodeGLCursor(res.Next.Encode())
require.NoError(t, err)
page2, res := gl(t, e, p, &cur, 3)
require.Equal(t, "[1520 open 13800.0000]", page2[0])
require.Equal(t, "04-30 reversal 99.9900/0.0000 = 13300.0000", page2[3])
page3, res := gl(t, e, p, res.Next, 3)
require.Equal(t, []string{"[1520 open 13300.0000]",
"05-10 manual 0.0000/45.0000 = 13255.0000", "09-18 manual 0.0000/420.0000 = 12835.0000"}, page3)
require.Nil(t, res.Next, "exactly limit lines left: no further page")
}
func TestAccountsComeInCodeOrderWithTheirOpenings(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
out, _ := gl(t, e, GeneralLedgerParams{Range: fy("2027")}, nil, 0)
require.Equal(t, []string{
"[1520 open 12835.0000]",
"01-15 manual 0.0000/200.0000 = 12635.0000",
"01-20 manual 800.0000/0.0000 = 13435.0000",
"[4100 open 0.0000]",
"01-20 manual 0.0000/800.0000 = -800.0000",
"[6100 open 0.0000]",
"01-15 manual 200.0000/0.0000 = 200.0000",
}, out, "Cash (1510) has no line in FY2027 and is not listed (Ruling R11)")
}
func TestTheLedgerIncludesTheClosingEntry(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
out, _ := gl(t, e, GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Books.Chart.Revenue.ID}}, nil, 0)
require.Equal(t, []string{"[4100 open 0.0000]",
"02-10 manual 0.0000/5000.0000 = -5000.0000",
"12-31 year_end_close 5000.0000/0.0000 = 0.0000"}, out)
}
func TestAnAnalyticLedgerShowsShares(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
c := &collect{}
var lines []GLLine
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
_, err := GeneralLedger(ctx, tx, GeneralLedgerParams{Range: fy("2026"),
Scope: Scope{AnalyticAccountIDs: []uuid.UUID{g.Books.Axes.HQ.ID}}}, nil, 0,
visitFuncs{c.Account, func(l GLLine) error { lines = append(lines, l); return c.Line(l) }})
require.NoError(t, err)
})
require.Equal(t, []string{
"[4100 open 0.0000]", "02-10 manual 0.0000/3000.0000 = -3000.0000",
"[6100 open 0.0000]", "03-15 manual 1200.0000/0.0000 = 1200.0000", "09-18 manual 210.0000/0.0000 = 1410.0000",
}, c.out, "the closing entry carries no distribution, so it is not in an analytic ledger")
for _, l := range lines {
require.Nil(t, l.TxDebit)
require.Nil(t, l.TxCredit)
}
}
func TestTransactionAmountsRideAlong(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
var e9 GLLine
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
_, err := GeneralLedger(ctx, tx, GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Books.Chart.Travel.ID}}, nil, 0,
visitFuncs{func(GLAccount) error { return nil }, func(l GLLine) error {
if l.EntryID == g.Entries["E9"] {
e9 = l
}
return nil
}})
require.NoError(t, err)
})
require.Equal(t, "420.0000", e9.Debit.String(), "base")
require.Equal(t, "USD", e9.Currency)
require.Equal(t, "100.0000", e9.TxDebit.String(), "transaction currency, informative")
require.Equal(t, "MSC", e9.JournalCode)
require.NotEmpty(t, e9.EntryNo)
}
func TestCountMatchesTheStream(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{})
readTx(t, e, func(ctx context.Context, tx pgx.Tx) {
n, err := CountGeneralLedger(ctx, tx, GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AccountID: &g.Books.Chart.Bank.ID}})
require.NoError(t, err)
require.EqualValues(t, 8, n)
n, err = CountGeneralLedger(ctx, tx, GeneralLedgerParams{Range: fy("2026"), Scope: Scope{AnalyticAccountIDs: []uuid.UUID{g.Books.Axes.HQ.ID}}})
require.NoError(t, err)
require.EqualValues(t, 3, n)
})
}
func TestAVisitorErrorStopsTheStream(t *testing.T) {
e := accountingtest.New(t)
reportingtest.Seed(t, e, reportingtest.Options{})
stop := errors.New("stop")
n := 0
err := e.DB.InReadOnlyTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := GeneralLedger(ctx, tx, GeneralLedgerParams{Range: fy("2026")}, nil, 0,
visitFuncs{func(GLAccount) error { return nil }, func(GLLine) error { n++; return stop }})
return err
})
require.ErrorIs(t, err, stop)
require.Equal(t, 1, n)
}
func TestABadCursorIsRefused(t *testing.T) {
good := GLCursor{Code: "1520", AccountID: uuid.New(), EntryDate: reportingtest.Date("2026-03-15"), PostedSeq: 9, LineNo: 2}
back, err := DecodeGLCursor(good.Encode())
require.NoError(t, err)
require.Equal(t, good, back)
for _, bad := range []string{"", "!!", "e30", base64.RawURLEncoding.EncodeToString([]byte(`{"code":"1520"}`))} {
_, err := DecodeGLCursor(bad)
require.ErrorIs(t, err, ErrBadCursor, bad)
}
}

visitFuncs is a test-and-production helper in generalledger.go: type visitFuncs struct{ account func(GLAccount) error; line func(GLLine) error } implementing GLVisitor (unexported; the module and export define their own visitors).

  • Step 2: Run and watch fail. go test ./internal/platform/reporting/ -run 'Ledger|Pages|Accounts|Cursor|Count|Visitor|Transaction' -count=1 → FAIL.

  • Step 3: Implement cursor.go.

type cursorWire struct {
Code string `json:"c"`
AccountID uuid.UUID `json:"a"`
EntryDate string `json:"d"`
PostedSeq int64 `json:"s"`
LineNo int `json:"l"`
}
func (c GLCursor) Encode() string {
b, _ := json.Marshal(cursorWire{c.Code, c.AccountID, c.EntryDate.Format("2006-01-02"), c.PostedSeq, c.LineNo})
return base64.RawURLEncoding.EncodeToString(b)
}
func DecodeGLCursor(s string) (GLCursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(raw) == 0 {
return GLCursor{}, ErrBadCursor
}
var w cursorWire
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&w); err != nil || w.Code == "" || w.AccountID == uuid.Nil || w.PostedSeq <= 0 || w.LineNo < 1 {
return GLCursor{}, ErrBadCursor
}
d, err := time.Parse("2006-01-02", w.EntryDate)
if err != nil {
return GLCursor{}, ErrBadCursor
}
return GLCursor{Code: w.Code, AccountID: w.AccountID, EntryDate: d, PostedSeq: w.PostedSeq, LineNo: w.LineNo}, nil
}
  • Step 4: Implement generalledger.go. Four SQL constants (plain and analytic, for the stream and for the cursor prefix) plus two counts. The stream:
const glSQL = `
SELECT a.id, a.code, e.id, e.entry_no, e.entry_date, e.posted_seq, j.code, e.semantic, e.description,
l.memo, l.line_no, l.base_debit, l.base_credit, l.currency, l.debit, l.credit,
e.source_kind, e.source_id, e.reversal_of_id
FROM journal_line l
JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id
JOIN journal j ON j.org_id = e.org_id AND j.id = e.journal_id
WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1 AND j.org_id = $1
AND e.entry_date >= $2 AND e.entry_date <= $3
AND ($4::text = '' OR a.path LIKE $4 || '%')
AND (NOT $5::boolean OR (a.code, a.id, e.entry_date, e.posted_seq, l.line_no) > ($6::text, $7::uuid, $8::date, $9::bigint, $10::int))
ORDER BY a.code, a.id, e.entry_date, e.posted_seq, l.line_no
LIMIT $11`
const glAnalyticSQL = `
SELECT a.id, a.code, e.id, e.entry_no, e.entry_date, e.posted_seq, j.code, e.semantic, e.description,
l.memo, l.line_no,
CASE WHEN l.base_debit > 0 THEN s.share ELSE 0 END,
CASE WHEN l.base_credit > 0 THEN s.share ELSE 0 END,
l.currency, NULL::numeric, NULL::numeric,
e.source_kind, e.source_id, e.reversal_of_id
FROM (SELECT d.journal_line_id, sum(d.base_amount) AS share
FROM analytic_distribution d
WHERE d.org_id = $1 AND d.analytic_account_id = ANY($12)
GROUP BY d.journal_line_id) s
JOIN journal_line l ON l.org_id = $1 AND l.id = s.journal_line_id
JOIN journal_entry e ON e.org_id = l.org_id AND e.id = l.entry_id
JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id
JOIN journal j ON j.org_id = e.org_id AND j.id = e.journal_id
WHERE l.org_id = $1 AND e.org_id = $1 AND a.org_id = $1 AND j.org_id = $1
AND e.entry_date >= $2 AND e.entry_date <= $3
AND ($4::text = '' OR a.path LIKE $4 || '%')
AND (NOT $5::boolean OR (a.code, a.id, e.entry_date, e.posted_seq, l.line_no) > ($6::text, $7::uuid, $8::date, $9::bigint, $10::int))
ORDER BY a.code, a.id, e.entry_date, e.posted_seq, l.line_no
LIMIT $11`

The cursor prefix — the lines of the cursor’s account in [from, cursor], needed for the first section’s opening on a later page — is prefixSQL / prefixAnalyticSQL: the same FROM … WHERE as the stream (without journal), AND l.account_id = $5 AND (e.entry_date, e.posted_seq, l.line_no) <= ($6::date, $7::bigint, $8::int), selecting coalesce(sum(<debit expr>) - sum(<credit expr>), 0). The counts, glCountSQL / glCountAnalyticSQL, are the stream’s FROM … WHERE without the cursor clause, selecting count(*). Write all six as literal constants (the tenant-predicate guard reads literals only).

The function:

func GeneralLedger(ctx context.Context, tx pgx.Tx, p GeneralLedgerParams, after *GLCursor, limit int, v GLVisitor) (GLResult, error) {
if err := p.Range.Validate(); err != nil {
return GLResult{}, err
}
if limit < 0 || limit > MaxGLPage {
return GLResult{}, fmt.Errorf("reporting: limit %d outside 0..%d", limit, MaxGLPage)
}
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return GLResult{}, fmt.Errorf("reporting: %w", tenancy.ErrNoOrg)
}
cv, err := loadChart(ctx, tx)
if err != nil {
return GLResult{}, err
}
scope, err := resolveScope(ctx, tx, cv, p.Scope)
if err != nil {
return GLResult{}, err
}
res, err := GeneralLedgerHeader(ctx, tx, p)
if err != nil {
return GLResult{}, err
}
// Openings before streaming: pgx cannot run a second query on this
// connection while the stream's rows are open. At most one per account.
openings, err := movements(ctx, tx, movementQuery{To: dayBefore(p.Range.From), Scope: scope})
if err != nil {
return GLResult{}, err
}
var prefix money.Amount
if after != nil {
if prefix, err = cursorPrefix(ctx, tx, orgID, p, scope, *after); err != nil {
return GLResult{}, err
}
}
var lim *int
if limit > 0 {
n := limit + 1 // one extra row says whether a next page exists
lim = &n
}
sql := glSQL
args := []any{orgID, p.Range.From, p.Range.To, scope.pathPrefix, after != nil, "", uuid.Nil, p.Range.From, int64(0), 0, lim}
if after != nil {
args[5], args[6], args[7], args[8], args[9] = after.Code, after.AccountID, after.EntryDate, after.PostedSeq, after.LineNo
}
if scope.analytic() {
sql, args = glAnalyticSQL, append(args, scope.analyticIDs)
}
rows, err := tx.Query(ctx, sql, args...)
if err != nil {
return GLResult{}, fmt.Errorf("reporting: general ledger: %w", err)
}
defer rows.Close()
var current uuid.UUID
var running money.Amount
var last GLCursor
delivered := 0
for rows.Next() {
var l GLLine
var code string
var seq int64
var txD, txC *money.Amount
if err := rows.Scan(&l.AccountID, &code, &l.EntryID, &l.EntryNo, &l.EntryDate, &seq, &l.JournalCode, &l.Semantic,
&l.Description, &l.Memo, &l.LineNo, &l.Debit, &l.Credit, &l.Currency, &txD, &txC,
&l.SourceKind, &l.SourceID, &l.ReversalOfID); err != nil {
return GLResult{}, fmt.Errorf("reporting: general ledger: %w", err)
}
if limit > 0 && delivered == limit {
next := last
res.Next = &next
break
}
if l.AccountID != current {
current = l.AccountID
running = lookup(openings, current).net()
if after != nil && current == after.AccountID {
running = running.Add(prefix)
}
if err := v.Account(GLAccount{Account: cv.ref(current), Opening: running}); err != nil {
return GLResult{}, err
}
}
running = running.Add(l.Debit).Sub(l.Credit)
l.Balance, l.TxDebit, l.TxCredit = running, txD, txC
if err := v.Line(l); err != nil {
return GLResult{}, err
}
last = GLCursor{Code: code, AccountID: l.AccountID, EntryDate: l.EntryDate, PostedSeq: seq, LineNo: l.LineNo}
delivered++
}
if err := rows.Err(); err != nil {
return GLResult{}, fmt.Errorf("reporting: general ledger: %w", err)
}
return res, nil
}

GeneralLedgerHeader validates the range and fills Filtered: p.Scope.Filtered(), Currency (baseCurrency) and Provisional (provisional(ctx, tx, &from, p.Range.To)). cursorPrefix runs prefixSQL/prefixAnalyticSQL with $2=from, $3=to, $4=path prefix, $5=cursor account, $6..$8=cursor date/seq/line ($9 = analytic ids) and returns the signed sum. CountGeneralLedger resolves the scope and runs the count query. visitFuncs adapts two funcs to GLVisitor. Scanning NULL::numeric into *money.Amount yields nil (money.Amount.Scan is only called for non-NULL values through the pointer).

  • Step 5: Run. go test ./internal/platform/reporting/... ./internal/kernel/layering/ -count=1 → PASS.

  • Step 6: Sabotage once. Drop the after != nil && current == after.AccountID prefix addition → TestPagesCarryTheirOpeningBalance red (page 2 opens at 0). Restore; quote it.

  • Step 7: Commit

Terminal window
git add internal/platform/reporting/generalledger.go internal/platform/reporting/cursor.go internal/platform/reporting/generalledger_test.go
git commit -m "Stream general-ledger detail with keyset pages and running balances
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 10: render — the writer contract, CSV and XLSX, exact decimals

Section titled “Task 10: render — the writer contract, CSV and XLSX, exact decimals”

Files:

  • Create: internal/platform/reporting/render/render.go, csv.go, xlsx.go
  • Test: internal/platform/reporting/render/render_test.go, csv_test.go, xlsx_test.go, nofloat_test.go
  • Modify: go.mod, go.sum (github.com/xuri/excelize/v2@v2.11.0, used only by xlsx_test.go)

Interfaces:

  • Consumes: money.Amount.

  • Produces:

    package render
    type Format string
    const (
    FormatCSV Format = "csv"
    FormatXLSX Format = "xlsx"
    FormatPDF Format = "pdf"
    )
    func (f Format) Valid() bool
    type Cell struct {
    Text string
    Amount *money.Amount // non-nil: a money cell
    Bold bool
    }
    func Text(s string) Cell
    func Money(a money.Amount) Cell
    func Bold(c Cell) Cell
    type Column struct {
    Title string
    Numeric bool // a money column: right-aligned in PDF
    Weight int // relative PDF width, >= 1
    OmitInPDF bool // CSV and XLSX carry every column; PDF may drop some (Ruling R23)
    }
    type Meta struct {
    Title string // "Trial balance"
    Fields [][2]string // ordered key/value lines printed above the table
    Footer string // PDF page footer
    Landscape bool // PDF orientation
    }
    type Writer interface {
    Begin(m Meta, cols []Column) error
    Row(cells []Cell) error // len(cells) <= len(cols); missing trailing cells are blank
    Finish() ([]byte, error)
    }
    var ErrRowWidth = errors.New("render: a row has more cells than the table has columns")
    // New returns the writer for f and the file's content type and extension.
    func New(f Format) (w Writer, contentType, ext string, err error)
    // FormatAmount groups thousands and shows 2–4 decimals, dropping only
    // trailing zeros past the second: lossless (Ruling R25).
    func FormatAmount(a money.Amount) string

    Content types: text/csv; charset=utf-8, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/pdf. New(FormatPDF) returns Task 11’s writer; until Task 11 lands it returns errors.New("render: pdf is not built yet") — Task 11 replaces that line.

  • Step 1: Write the failing tests. render_test.go:

func TestFormatAmountIsLossless(t *testing.T) {
for in, want := range map[string]string{
"0": "0.00", "1234.5": "1,234.50", "-12345.67": "-12,345.67", "1000000": "1,000,000.00",
"0.9999": "0.9999", "0.001": "0.001", "-0.5": "-0.50", "999999999999999.9999": "999,999,999,999,999.9999",
} {
require.Equal(t, want, render.FormatAmount(money.MustParse(in)), in)
}
}
func TestNewKnowsEveryFormat(t *testing.T) {
for f, ct := range map[render.Format]string{
render.FormatCSV: "text/csv; charset=utf-8",
render.FormatXLSX: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
} {
_, got, ext, err := render.New(f)
require.NoError(t, err)
require.Equal(t, ct, got)
require.Equal(t, string(f), ext)
}
require.False(t, render.Format("docx").Valid())
_, _, _, err := render.New("docx")
require.Error(t, err)
}

csv_test.go:

func sample(t *testing.T, f render.Format) []byte {
t.Helper()
w, _, _, err := render.New(f)
require.NoError(t, err)
require.NoError(t, w.Begin(render.Meta{Title: "Trial balance",
Fields: [][2]string{{"Organization", "Env Org"}, {"From", "2026-01-01"}}},
[]render.Column{{Title: "Code", Weight: 1}, {Title: "Account", Weight: 3}, {Title: "Debit", Numeric: true, Weight: 2}}))
require.NoError(t, w.Row([]render.Cell{render.Text("1520"), render.Text("Bank & Co <x>"), render.Money(money.MustParse("12835"))}))
require.NoError(t, w.Row([]render.Cell{render.Text("6100"), render.Text(`=HYPERLINK("x")`), render.Money(money.MustParse("-1.5"))}))
require.NoError(t, w.Row([]render.Cell{render.Bold(render.Text("Total"))}))
require.ErrorIs(t, w.Row(make([]render.Cell, 4)), render.ErrRowWidth)
b, err := w.Finish()
require.NoError(t, err)
return b
}
func TestCSVIsExactAndSafe(t *testing.T) {
got := string(sample(t, render.FormatCSV))
require.Equal(t, ""+
"Report,Trial balance\r\n"+
"Organization,Env Org\r\n"+
"From,2026-01-01\r\n"+
"\r\n"+
"Code,Account,Debit\r\n"+
"1520,Bank & Co <x>,12835.0000\r\n"+
`6100,"'=HYPERLINK(""x"")",-1.5000`+"\r\n"+
"Total,,\r\n", got,
"BOM, CRLF, metadata, blank line, header; amounts exact; a formula-looking text is defused (Ruling R24), a negative amount is not")
}

xlsx_test.go:

func TestXLSXMoneyCellsAreExactDecimalsAndExcelReadsThem(t *testing.T) {
b := sample(t, render.FormatXLSX)
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
require.NoError(t, err)
var sheet string
for _, f := range zr.File {
if f.Name == "xl/worksheets/sheet1.xml" {
rc, err := f.Open()
require.NoError(t, err)
raw, _ := io.ReadAll(rc)
rc.Close()
sheet = string(raw)
}
}
// Rows: 1 title, 2–3 fields, 4 blank, 5 header, 6–7 data, 8 total.
require.Contains(t, sheet, `<c r="C6" s="1"><v>12835.0000</v></c>`, "a numeric cell holding Amount.String() verbatim")
require.Contains(t, sheet, `<c r="C7" s="1"><v>-1.5000</v></c>`)
f, err := excelize.OpenReader(bytes.NewReader(b))
require.NoError(t, err)
v, err := f.GetCellValue("Report", "C6", excelize.Options{RawCellValue: true})
require.NoError(t, err)
require.Equal(t, "12835.0000", v)
name, err := f.GetCellValue("Report", "B6")
require.NoError(t, err)
require.Equal(t, "Bank & Co <x>", name, "text is escaped in the XML and read back intact")
title, err := f.GetCellValue("Report", "A1")
require.NoError(t, err)
require.Equal(t, "Trial balance", title)
}
func TestXLSXDropsCharactersXMLCannotHold(t *testing.T) {
w, _, _, err := render.New(render.FormatXLSX)
require.NoError(t, err)
require.NoError(t, w.Begin(render.Meta{Title: "T"}, []render.Column{{Title: "Memo", Weight: 1}}))
require.NoError(t, w.Row([]render.Cell{render.Text("a\x00b\x07c")}))
b, err := w.Finish()
require.NoError(t, err)
f, err := excelize.OpenReader(bytes.NewReader(b))
require.NoError(t, err)
v, _ := f.GetCellValue("Report", "A3")
require.Equal(t, "abc", v)
}

nofloat_test.go:

// TestNoFloatTouchesAnAmount (Global Constraints): no float type or float
// conversion in any non-test file under internal/platform/reporting, except
// PDF page geometry in render/pdf.go (Ruling R23).
var floaty = regexp.MustCompile(`\bfloat(32|64)\b|ParseFloat|InexactFloat64|\.Float64\(\)`)
func TestNoFloatTouchesAnAmount(t *testing.T) {
require.True(t, floaty.MatchString("x := float64(1)"), "the pattern must match a real float")
var scanned int
var offenders []string
root := filepath.Join("..")
require.NoError(t, filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
if filepath.ToSlash(path) == "../render/pdf.go" {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
scanned++
if floaty.Match(src) {
offenders = append(offenders, path)
}
return nil
}))
require.Greater(t, scanned, 5, "the walk reached no source files")
require.Empty(t, offenders, "a float never touches money: %v", offenders)
}
  • Step 2: Run and watch fail. go get github.com/xuri/excelize/v2@v2.11.0, then go test ./internal/platform/reporting/render/ -count=1 → FAIL (undefined).

  • Step 3: Implement render.go.

// Package render writes a report's rows as CSV, XLSX or PDF (Plan 9
// Rulings R22–R25). It knows nothing about reports or the database: a
// caller begins a table, writes rows of cells, and finishes with bytes.
// Money cells hold a money.Amount and are written exactly; no float ever
// holds an amount.
package render
type Format string
const (
FormatCSV Format = "csv"
FormatXLSX Format = "xlsx"
FormatPDF Format = "pdf"
)
func (f Format) Valid() bool { return f == FormatCSV || f == FormatXLSX || f == FormatPDF }
type Cell struct {
Text string
Amount *money.Amount
Bold bool
}
func Text(s string) Cell { return Cell{Text: s} }
func Money(a money.Amount) Cell { return Cell{Amount: &a} }
func Bold(c Cell) Cell { c.Bold = true; return c }
type Column struct {
Title string
Numeric bool
Weight int
OmitInPDF bool
}
type Meta struct {
Title string
Fields [][2]string
Footer string
Landscape bool
}
type Writer interface {
Begin(m Meta, cols []Column) error
Row(cells []Cell) error
Finish() ([]byte, error)
}
var ErrRowWidth = errors.New("render: a row has more cells than the table has columns")
func New(f Format) (Writer, string, string, error) {
switch f {
case FormatCSV:
return newCSV(), "text/csv; charset=utf-8", "csv", nil
case FormatXLSX:
return newXLSX(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx", nil
case FormatPDF:
return nil, "", "", errors.New("render: pdf is not built yet") // Task 11
}
return nil, "", "", fmt.Errorf("render: unknown format %q", f)
}
func FormatAmount(a money.Amount) string {
s := a.String() // always 4 decimals
neg := strings.HasPrefix(s, "-")
intPart, frac, _ := strings.Cut(strings.TrimPrefix(s, "-"), ".")
frac = strings.TrimRight(frac, "0")
for len(frac) < 2 {
frac += "0"
}
var b strings.Builder
if neg {
b.WriteByte('-')
}
for i := range intPart {
if i > 0 && (len(intPart)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteByte(intPart[i])
}
b.WriteByte('.')
b.WriteString(frac)
return b.String()
}

(Amount.String() of a negative zero is "0.0000"; if it can print "-0.0000", strip the sign when the digits are all zero, and add "-0" to the test table.)

  • Step 4: Implement csv.go.
type csvWriter struct {
buf bytes.Buffer
w *csv.Writer
cols int
}
func newCSV() *csvWriter {
c := &csvWriter{}
c.buf.WriteString("") // Excel reads BOM-less UTF-8 as ANSI (Ruling R24)
c.w = csv.NewWriter(&c.buf)
c.w.UseCRLF = true
return c
}
// safeText defuses spreadsheet formula injection (OWASP): a cell that would
// start a formula starts with an apostrophe instead.
func safeText(s string) string {
if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) {
return "'" + s
}
return s
}
func (c *csvWriter) Begin(m Meta, cols []Column) error {
c.cols = len(cols)
if err := c.w.Write([]string{"Report", safeText(m.Title)}); err != nil {
return err
}
for _, f := range m.Fields {
if err := c.w.Write([]string{safeText(f[0]), safeText(f[1])}); err != nil {
return err
}
}
if err := c.w.Write([]string{""}); err != nil {
return err
}
titles := make([]string, len(cols))
for i, col := range cols {
titles[i] = safeText(col.Title)
}
return c.w.Write(titles)
}
func (c *csvWriter) Row(cells []Cell) error {
if len(cells) > c.cols {
return ErrRowWidth
}
rec := make([]string, c.cols)
for i, cell := range cells {
if cell.Amount != nil {
rec[i] = cell.Amount.String()
} else {
rec[i] = safeText(cell.Text)
}
}
return c.w.Write(rec)
}
func (c *csvWriter) Finish() ([]byte, error) {
c.w.Flush()
return c.buf.Bytes(), c.w.Error()
}
  • Step 5: Implement xlsx.go (Ruling R22 — a minimal SpreadsheetML package: five fixed parts and one streamed sheet).
type xlsxWriter struct {
buf bytes.Buffer
zw *zip.Writer
sheet io.Writer
cols int
row int
}
func newXLSX() *xlsxWriter {
x := &xlsxWriter{}
x.zw = zip.NewWriter(&x.buf)
return x
}
const (
contentTypesXML = `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>` +
`</Types>`
rootRelsXML = `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`
workbookXML = `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets><sheet name="Report" sheetId="1" r:id="rId1"/></sheets></workbook>`
workbookRelsXML = `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>` +
`</Relationships>`
// Styles: 0 text, 1 money (#,##0.00## — Ruling R25), 2 bold text, 3 bold money.
stylesXML = `<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
`<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0.00##"/></numFmts>` +
`<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>` +
`<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>` +
`<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +
`<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>` +
`<cellXfs count="4">` +
`<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>` +
`<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>` +
`<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>` +
`<xf numFmtId="164" fontId="1" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyFont="1"/>` +
`</cellXfs></styleSheet>`
)
func (x *xlsxWriter) part(name, body string) error {
w, err := x.zw.Create(name)
if err != nil {
return err
}
_, err = io.WriteString(w, xml.Header+body)
return err
}
func (x *xlsxWriter) Begin(m Meta, cols []Column) error {
x.cols = len(cols)
for _, p := range [][2]string{
{"[Content_Types].xml", contentTypesXML}, {"_rels/.rels", rootRelsXML},
{"xl/workbook.xml", workbookXML}, {"xl/_rels/workbook.xml.rels", workbookRelsXML}, {"xl/styles.xml", stylesXML},
} {
if err := x.part(p[0], p[1]); err != nil {
return err
}
}
// The sheet is the last entry, so it can stream: a zip.Writer holds one
// open entry at a time.
w, err := x.zw.Create("xl/worksheets/sheet1.xml")
if err != nil {
return err
}
x.sheet = w
if _, err := io.WriteString(w, xml.Header+`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`); err != nil {
return err
}
if err := x.write([]Cell{Bold(Text(m.Title))}); err != nil {
return err
}
for _, f := range m.Fields {
if err := x.write([]Cell{Text(f[0]), Text(f[1])}); err != nil {
return err
}
}
if err := x.write(nil); err != nil {
return err
}
titles := make([]Cell, len(cols))
for i, c := range cols {
titles[i] = Bold(Text(c.Title))
}
return x.write(titles)
}
func (x *xlsxWriter) Row(cells []Cell) error {
if len(cells) > x.cols {
return ErrRowWidth
}
return x.write(cells)
}
func (x *xlsxWriter) write(cells []Cell) error {
x.row++
var b strings.Builder
fmt.Fprintf(&b, `<row r="%d">`, x.row)
for i, c := range cells {
ref := colName(i) + strconv.Itoa(x.row)
switch {
case c.Amount != nil:
style := 1
if c.Bold {
style = 3
}
// Amount.String() is a valid xsd:double lexical form, exact to
// four places: the file holds the decimal, not a float.
fmt.Fprintf(&b, `<c r="%s" s="%d"><v>%s</v></c>`, ref, style, c.Amount.String())
case c.Text != "":
style := 0
if c.Bold {
style = 2
}
fmt.Fprintf(&b, `<c r="%s" s="%d" t="inlineStr"><is><t xml:space="preserve">`, ref, style)
if err := xml.EscapeText(&b, []byte(xmlSafe(c.Text))); err != nil {
return err
}
b.WriteString(`</t></is></c>`)
}
}
b.WriteString(`</row>`)
_, err := io.WriteString(x.sheet, b.String())
return err
}
func (x *xlsxWriter) Finish() ([]byte, error) {
if _, err := io.WriteString(x.sheet, `</sheetData></worksheet>`); err != nil {
return nil, err
}
if err := x.zw.Close(); err != nil {
return nil, err
}
return x.buf.Bytes(), nil
}
// colName is the spreadsheet column letter(s) for a zero-based index.
func colName(i int) string {
s := ""
for i++; i > 0; i = (i - 1) / 26 {
s = string(rune('A'+(i-1)%26)) + s
}
return s
}
// xmlSafe drops the characters XML 1.0 cannot carry at all.
func xmlSafe(s string) string {
return strings.Map(func(r rune) rune {
if (r < 0x20 && r != '\t' && r != '\n' && r != '\r') || r == 0xFFFE || r == 0xFFFF {
return -1
}
return r
}, s)
}
  • Step 6: Run. go test ./internal/platform/reporting/render/ -count=1 → PASS.

  • Step 7: Sabotage once. (a) In xlsx.go write strconv.FormatFloat(…) of a parsed amount → TestNoFloatTouchesAnAmount red (and the exact-<v> assertion if it compiles). (b) Remove safeText from Row → TestCSVIsExactAndSafe red. Restore; quote both.

  • Step 8: Commit

Terminal window
git add internal/platform/reporting/render go.mod go.sum
git commit -m "Render report tables as CSV and XLSX with exact decimal cells
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/reporting/render/pdf.go
  • Test: internal/platform/reporting/render/pdf_test.go (package render, internal: it reads the page count)
  • Modify: internal/platform/reporting/render/render.go (New(FormatPDF)), go.mod, go.sum (github.com/signintech/gopdf@v0.38.1, golang.org/x/image for font/gofont/goregular and font/gofont/gobold)

Interfaces:

  • Consumes: Task 10’s Writer, Cell, Column, Meta, FormatAmount, ErrRowWidth.

  • Produces: New(FormatPDF) → (*pdfWriter, "application/pdf", "pdf", nil); var ErrAmountTooWide = errors.New("render: an amount does not fit its PDF column even at the smallest font").

  • Step 1: Write the failing tests — pdf_test.go:

func newPDFT(t *testing.T, landscape bool, cols []Column) *pdfWriter {
t.Helper()
p := newPDF()
require.NoError(t, p.Begin(Meta{Title: "Trial balance", Fields: [][2]string{{"Organization", "Env Org"}},
Footer: "Trial balance · Export 00000000-0000-0000-0000-000000000000", Landscape: landscape}, cols))
return p
}
var tbCols = []Column{{Title: "Code", Weight: 5}, {Title: "Account", Weight: 15},
{Title: "Opening debit", Numeric: true, Weight: 10}, {Title: "Opening credit", Numeric: true, Weight: 10},
{Title: "Debit", Numeric: true, Weight: 10}, {Title: "Credit", Numeric: true, Weight: 10},
{Title: "Closing debit", Numeric: true, Weight: 10}, {Title: "Closing credit", Numeric: true, Weight: 10}}
func TestAPDFIsAPDF(t *testing.T) {
p := newPDFT(t, true, tbCols)
require.NoError(t, p.Row([]Cell{Text("1520"), Text("Bank"), Money(money.MustParse("12835"))}))
b, err := p.Finish()
require.NoError(t, err)
require.True(t, bytes.HasPrefix(b, []byte("%PDF-")))
require.Contains(t, string(b[len(b)-32:]), "%%EOF")
}
func TestLongTablesBreakPages(t *testing.T) {
p := newPDFT(t, true, tbCols)
require.Equal(t, 1, p.pages)
for i := 0; i < 300; i++ {
require.NoError(t, p.Row([]Cell{Text(fmt.Sprint(i)), Text("Row"), Money(money.MustParse("1"))}))
}
require.Greater(t, p.pages, 1)
}
// TestTheWidestAmountFits: NUMERIC(19,4)'s widest value fits a TB money
// column in landscape, shrinking the font if it must; amounts are never cut.
func TestTheWidestAmountFits(t *testing.T) {
p := newPDFT(t, true, tbCols)
widest := money.MustParse("-999999999999999.9999")
require.NoError(t, p.Row([]Cell{Text("9999"), Text("Widest"), Money(widest), Money(widest), Money(widest),
Money(widest), Money(widest), Money(widest)}))
// ABSENCE only after PRESENCE: a column far too narrow refuses rather than cutting.
q := newPDFT(t, false, []Column{{Title: "Account", Weight: 60}, {Title: "Amount", Numeric: true, Weight: 1}})
require.ErrorIs(t, q.Row([]Cell{Text("x"), Money(widest)}), ErrAmountTooWide)
}
func TestTextIsFittedWithAnEllipsis(t *testing.T) {
p := newPDFT(t, false, []Column{{Title: "Account", Weight: 1}})
long := strings.Repeat("Perbelanjaan perjalanan ", 20)
got, err := p.fit(long, 60)
require.NoError(t, err)
require.True(t, strings.HasSuffix(got, "…"))
w, err := p.pdf.MeasureTextWidth(got)
require.NoError(t, err)
require.LessOrEqual(t, w, 60.0)
short, err := p.fit("Bank", 60)
require.NoError(t, err)
require.Equal(t, "Bank", short)
}
func TestOmittedColumnsAreSkippedInPDFOnly(t *testing.T) {
cols := []Column{{Title: "Code", Weight: 1}, {Title: "Memo", Weight: 1, OmitInPDF: true}, {Title: "Debit", Numeric: true, Weight: 2}}
p := newPDFT(t, false, cols)
require.Len(t, p.cols, 2)
require.Equal(t, []int{0, 2}, p.idx)
require.NoError(t, p.Row([]Cell{Text("1"), Text("memo"), Money(money.MustParse("1"))}))
require.ErrorIs(t, p.Row(make([]Cell, 4)), ErrRowWidth)
}
  • Step 2: Run and watch fail. go get github.com/signintech/gopdf@v0.38.1 golang.org/x/image@latest; go test ./internal/platform/reporting/render/ -run PDF -count=1 → FAIL.

  • Step 3: Implement pdf.go.

// PDF: gopdf with the Go fonts embedded (Ruling R23). float64 in this file is
// page geometry only — points on an A4 page. Amounts arrive as money.Amount
// and are drawn as FormatAmount's text; no amount is ever converted to a
// float (nofloat_test.go exempts this file for geometry, not for money).
package render
const (
pdfMargin = 36.0
pdfRowH = 12.0
pdfFooterH = 18.0
pdfFontSize = 8.0
pdfMinFont = 5.0
pdfPad = 2.0
)
var ErrAmountTooWide = errors.New("render: an amount does not fit its PDF column even at the smallest font")
type pdfWriter struct {
pdf *gopdf.GoPdf
meta Meta
all int // columns the caller declared
cols []Column // columns drawn
idx []int // index into the caller's row for each drawn column
widths []float64
page gopdf.Rect
y float64
pages int
}
func newPDF() *pdfWriter { return &pdfWriter{} }
func (p *pdfWriter) Begin(m Meta, cols []Column) error {
p.meta, p.all = m, len(cols)
p.page = *gopdf.PageSizeA4
if m.Landscape {
p.page = gopdf.Rect{W: p.page.H, H: p.page.W}
}
p.pdf = &gopdf.GoPdf{}
p.pdf.Start(gopdf.Config{PageSize: p.page, Unit: gopdf.UnitPT})
p.pdf.SetInfo(gopdf.PdfInfo{Title: m.Title, Creator: "ERP reports"})
if err := p.pdf.AddTTFFontData("regular", goregular.TTF); err != nil {
return err
}
if err := p.pdf.AddTTFFontData("bold", gobold.TTF); err != nil {
return err
}
total := 0
for i, c := range cols {
if c.OmitInPDF {
continue
}
w := c.Weight
if w < 1 {
w = 1
}
p.cols, p.idx, total = append(p.cols, c), append(p.idx, i), total+w
}
usable := p.page.W - 2*pdfMargin
for _, c := range p.cols {
w := c.Weight
if w < 1 {
w = 1
}
p.widths = append(p.widths, usable*float64(w)/float64(total))
}
return p.newPage()
}
func (p *pdfWriter) newPage() error {
p.pdf.AddPage()
p.pages++
// Footer first, at the bottom margin.
if err := p.pdf.SetFont("regular", "", 7); err != nil {
return err
}
p.pdf.SetXY(pdfMargin, p.page.H-pdfMargin)
if err := p.pdf.Cell(nil, fmt.Sprintf("%s · page %d", p.meta.Footer, p.pages)); err != nil {
return err
}
p.y = pdfMargin
if p.pages == 1 {
if err := p.pdf.SetFont("bold", "", 14); err != nil {
return err
}
p.pdf.SetXY(pdfMargin, p.y)
if err := p.pdf.Cell(nil, p.meta.Title); err != nil {
return err
}
p.y += 20
if err := p.pdf.SetFont("regular", "", 9); err != nil {
return err
}
for _, f := range p.meta.Fields {
p.pdf.SetXY(pdfMargin, p.y)
if err := p.pdf.Cell(nil, f[0]+": "+f[1]); err != nil {
return err
}
p.y += 11
}
p.y += 6
}
header := make([]Cell, p.all)
for j, c := range p.cols {
header[p.idx[j]] = Bold(Text(c.Title))
}
return p.draw(header, true)
}
func (p *pdfWriter) Row(cells []Cell) error {
if len(cells) > p.all {
return ErrRowWidth
}
if p.y+pdfRowH > p.page.H-pdfMargin-pdfFooterH {
if err := p.newPage(); err != nil {
return err
}
}
return p.draw(cells, false)
}
func (p *pdfWriter) draw(cells []Cell, header bool) error {
x := pdfMargin
for j, col := range p.cols {
var c Cell
if p.idx[j] < len(cells) {
c = cells[p.idx[j]]
}
font := "regular"
if c.Bold {
font = "bold"
}
w := p.widths[j] - 2*pdfPad
size := pdfFontSize
if err := p.pdf.SetFont(font, "", size); err != nil {
return err
}
text, align := c.Text, gopdf.Left|gopdf.Middle
if col.Numeric && !header {
align = gopdf.Right | gopdf.Middle
}
if c.Amount != nil {
text = FormatAmount(*c.Amount)
// Shrink, never cut, an amount (Ruling R25).
for {
tw, err := p.pdf.MeasureTextWidth(text)
if err != nil {
return err
}
if tw <= w {
break
}
if size -= 0.5; size < pdfMinFont {
return ErrAmountTooWide
}
if err := p.pdf.SetFont(font, "", size); err != nil {
return err
}
}
} else {
var err error
if text, err = p.fit(text, w); err != nil {
return err
}
}
p.pdf.SetXY(x+pdfPad, p.y)
if err := p.pdf.CellWithOption(&gopdf.Rect{W: w, H: pdfRowH}, text, gopdf.CellOption{Align: align}); err != nil {
return err
}
x += p.widths[j]
}
p.y += pdfRowH
return nil
}
// fit cuts text to width w with a trailing ellipsis, in the current font.
func (p *pdfWriter) fit(text string, w float64) (string, error) {
tw, err := p.pdf.MeasureTextWidth(text)
if err != nil || tw <= w {
return text, err
}
r := []rune(text)
for len(r) > 0 {
r = r[:len(r)-1]
cand := strings.TrimRight(string(r), " ") + "…"
if tw, err = p.pdf.MeasureTextWidth(cand); err != nil {
return "", err
}
if tw <= w {
return cand, nil
}
}
return "", nil
}
func (p *pdfWriter) Finish() ([]byte, error) {
var buf bytes.Buffer
if err := p.pdf.Write(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

In render.go replace the PDF placeholder line with return newPDF(), "application/pdf", "pdf", nil, and add render.FormatPDF: "application/pdf" to TestNewKnowsEveryFormat’s map. TestTextIsFittedWithAnEllipsis calls fit after Begin has set a font; if gopdf’s MeasureTextWidth requires a font set on the current page, Begin’s last SetFont provides it.

  • Step 4: Run. go test ./internal/platform/reporting/render/ -count=1 → PASS (including TestNoFloatTouchesAnAmount, which skips only pdf.go).

  • Step 5: Commit

Terminal window
git add internal/platform/reporting/render go.mod go.sum
git commit -m "Render report tables as PDF with embedded Go fonts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 12: export — the request: parameters, the job, the record, the audit

Section titled “Task 12: export — the request: parameters, the job, the record, the audit”

Files:

  • Create: internal/platform/reporting/export/export.go, params.go, errors.go
  • Test: internal/platform/reporting/export/export_test.go, params_test.go

Interfaces:

  • Consumes: jobs.Inserter, jobs.QueueReports, jobs.NewInserter (tests) (Task 3); report_export (Task 5); reporting.Range, reporting.Scope, reporting.CheckScope, reporting.CountGeneralLedger, reporting.GeneralLedgerParams (Tasks 6, 9); render.Format (Task 10); audit.Write; reportingtest.Seed.

  • Produces:

    package export
    // Store is the object-store surface an export needs: the two methods of
    // Plan 5's documents.Store it uses, with identical signatures (Ruling R21).
    type Store interface {
    PresignGet(ctx context.Context, key, filename string, ttl time.Duration) (string, error)
    Put(ctx context.Context, key, contentType string, body []byte) error
    }
    const (
    ReportTrialBalance = "trial_balance"
    ReportProfitAndLoss = "profit_and_loss"
    ReportBalanceSheet = "balance_sheet"
    ReportGeneralLedger = "general_ledger"
    StatusQueued, StatusSucceeded, StatusFailed = "queued", "succeeded", "failed"
    FailureTooLarge, FailureInvalidParameters, FailureInternal = "too_large", "invalid_parameters", "internal"
    )
    // MaxLines caps a general-ledger export per format (Ruling R12). A var so
    // tests can lower it (restore in t.Cleanup; never t.Parallel).
    var MaxLines = map[render.Format]int64{render.FormatCSV: 250_000, render.FormatXLSX: 250_000, render.FormatPDF: 20_000}
    type Params struct {
    From string `json:"from,omitempty"` // "2006-01-02"
    To string `json:"to,omitempty"`
    AsOf string `json:"as_of,omitempty"`
    AccountID *uuid.UUID `json:"account_id,omitempty"`
    AnalyticAccountIDs []uuid.UUID `json:"analytic_account_ids,omitempty"`
    ExcludeClosing bool `json:"exclude_closing,omitempty"`
    }
    func (p Params) Validate(report string) error // *InvalidParamsError, ErrUnknownReport
    func (p Params) Range() reporting.Range // after Validate
    func (p Params) AsOfDate() time.Time // after Validate
    func (p Params) Scope() reporting.Scope
    type Request struct {
    Report string
    Format render.Format
    Params Params
    }
    type Export struct {
    ID uuid.UUID
    Report string
    Format render.Format
    Params Params
    Status string
    RequestedBy uuid.UUID
    RequestedAt time.Time
    RiverJobID int64
    FinishedAt *time.Time
    ObjectKey *string
    ContentType *string
    ByteSize *int64
    SHA256 []byte
    RowCount *int64
    FailureCode *string
    }
    func (x Export) Filename() string // "trial_balance_2026-01-01_2026-12-31.csv", "balance_sheet_2026-12-31.pdf"
    type JobArgs struct {
    OrgID uuid.UUID `json:"org_id"`
    ExportID uuid.UUID `json:"export_id"`
    }
    func (JobArgs) Kind() string // "report_export"
    func (JobArgs) InsertOpts() river.InsertOpts // Queue: jobs.QueueReports, MaxAttempts: 3
    // Create validates req, refuses a general ledger over its cap, enqueues the
    // job, writes the row and the request audit — all on the caller's tx.
    func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, ins jobs.Inserter, req Request, by uuid.UUID) (Export, error)
    func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Export, error) // ErrNotFound
    func List(ctx context.Context, tx pgx.Tx, limit, offset int) ([]Export, error) // newest first; limit 1..100, default 50
    var (
    ErrNotFound = errors.New("export: not found")
    ErrUnknownReport = errors.New("export: unknown report")
    ErrUnknownFormat = errors.New("export: unknown format")
    )
    type InvalidParamsError struct{ Field, Reason string } // Error(): "params.<field>: <reason>"
    type TooLargeError struct {
    Lines, Max int64
    Format render.Format
    }
  • Step 1: Write the failing tests. params_test.go:

func TestParamsAreCheckedAgainstTheirReport(t *testing.T) {
acct := uuid.New()
ok := map[string]export.Params{
export.ReportTrialBalance: {From: "2026-01-01", To: "2026-12-31", AccountID: &acct, ExcludeClosing: true},
export.ReportProfitAndLoss: {From: "2026-01-01", To: "2026-12-31", AnalyticAccountIDs: []uuid.UUID{uuid.New()}},
export.ReportBalanceSheet: {AsOf: "2026-12-31"},
export.ReportGeneralLedger: {From: "2026-01-01", To: "2026-01-31", AccountID: &acct},
}
for report, p := range ok { // PRESENCE: each report's own shape passes
require.NoError(t, p.Validate(report), report)
}
bad := []struct {
report string
p export.Params
field string
}{
{export.ReportTrialBalance, export.Params{To: "2026-12-31"}, "from"},
{export.ReportTrialBalance, export.Params{From: "2026-12-31", To: "2026-01-01"}, "to"},
{export.ReportTrialBalance, export.Params{From: "2026-01-01", To: "2026-12-31", AnalyticAccountIDs: []uuid.UUID{uuid.New()}}, "analytic_account_ids"},
{export.ReportTrialBalance, export.Params{From: "2026-1-1", To: "2026-12-31"}, "from"},
{export.ReportProfitAndLoss, export.Params{From: "2026-01-01", To: "2026-12-31", ExcludeClosing: true}, "exclude_closing"},
{export.ReportBalanceSheet, export.Params{AsOf: "2026-12-31", AccountID: &acct}, "account_id"},
{export.ReportBalanceSheet, export.Params{AsOf: "2026-12-31", From: "2026-01-01"}, "from"},
{export.ReportBalanceSheet, export.Params{}, "as_of"},
{export.ReportGeneralLedger, export.Params{From: "2026-01-01", To: "2026-12-31", AsOf: "2026-12-31"}, "as_of"},
}
for _, c := range bad {
var ip *export.InvalidParamsError
require.ErrorAs(t, c.p.Validate(c.report), &ip, "%s %+v", c.report, c.p)
require.Equal(t, c.field, ip.Field)
}
require.ErrorIs(t, export.Params{}.Validate("cash_flow"), export.ErrUnknownReport)
}
func TestFilenames(t *testing.T) {
x := export.Export{Report: export.ReportTrialBalance, Format: render.FormatCSV, Params: export.Params{From: "2026-01-01", To: "2026-12-31"}}
require.Equal(t, "trial_balance_2026-01-01_2026-12-31.csv", x.Filename())
x = export.Export{Report: export.ReportBalanceSheet, Format: render.FormatPDF, Params: export.Params{AsOf: "2026-12-31"}}
require.Equal(t, "balance_sheet_2026-12-31.pdf", x.Filename())
}

export_test.go:

type world struct {
e *accountingtest.Env
g reportingtest.Golden
ins *river.Client[pgx.Tx]
}
func newWorld(t *testing.T) world {
t.Helper()
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
ins, err := jobs.NewInserter(e.DB.Pool())
require.NoError(t, err)
return world{e: e, g: g, ins: ins}
}
func (w world) create(t *testing.T, req export.Request) (export.Export, error) {
t.Helper()
var x export.Export
err := w.e.DB.InTx(w.e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = export.Create(ctx, tx, w.e.Clock, w.ins, req, w.g.Member)
return err
})
return x, err
}
var tbFY2026 = export.Request{Report: export.ReportTrialBalance, Format: render.FormatCSV,
Params: export.Params{From: "2026-01-01", To: "2026-12-31"}}
func TestCreateQueuesAJobAndAuditsTheRequest(t *testing.T) {
w := newWorld(t)
x, err := w.create(t, tbFY2026)
require.NoError(t, err)
require.Equal(t, export.StatusQueued, x.Status)
require.Equal(t, w.g.Member, x.RequestedBy)
require.Positive(t, x.RiverJobID)
var kind, argExport, argOrg, queue string
require.NoError(t, w.e.H.Admin.QueryRow(context.Background(),
`SELECT kind, args->>'export_id', args->>'org_id', queue FROM river_job WHERE id = $1`, x.RiverJobID).
Scan(&kind, &argExport, &argOrg, &queue))
require.Equal(t, "report_export", kind)
require.Equal(t, x.ID.String(), argExport)
require.Equal(t, w.e.Org.String(), argOrg)
require.Equal(t, jobs.QueueReports, queue)
var actor uuid.UUID
var report string
require.NoError(t, w.e.H.Admin.QueryRow(context.Background(),
`SELECT actor_id, after->>'report' FROM audit_log
WHERE org_id = $1 AND entity_kind = 'report_export' AND entity_id = $2 AND action = 'request'`,
w.e.Org, x.ID).Scan(&actor, &report))
require.Equal(t, w.g.Member, actor, "who asked")
require.Equal(t, export.ReportTrialBalance, report, "which report")
got := w.get(t, x.ID)
require.Equal(t, x.Params, got.Params, "the parameters round-trip exactly")
}
// TestARolledBackRequestLeavesNoJob: §2's "enqueue in the same transaction".
// PRESENCE is the test above; here the caller's transaction fails after Create.
func TestARolledBackRequestLeavesNoJob(t *testing.T) {
w := newWorld(t)
boom := errors.New("later failure")
err := w.e.DB.InTx(w.e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, err := export.Create(ctx, tx, w.e.Clock, w.ins, tbFY2026, w.g.Member); err != nil {
return err
}
return boom
})
require.ErrorIs(t, err, boom)
var jobsN, rows int
require.NoError(t, w.e.H.Admin.QueryRow(context.Background(), `SELECT count(*) FROM river_job WHERE kind = 'report_export'`).Scan(&jobsN))
require.NoError(t, w.e.H.Admin.QueryRow(context.Background(), `SELECT count(*) FROM report_export`).Scan(&rows))
require.Zero(t, jobsN)
require.Zero(t, rows)
}
func TestCreateRefusesParametersThatDoNotResolve(t *testing.T) {
w := newWorld(t)
missing := uuid.New()
_, err := w.create(t, export.Request{Report: export.ReportTrialBalance, Format: render.FormatCSV,
Params: export.Params{From: "2026-01-01", To: "2026-12-31", AccountID: &missing}})
require.ErrorIs(t, err, reporting.ErrAccountNotFound)
ax := w.g.Books.Axes
_, err = w.create(t, export.Request{Report: export.ReportProfitAndLoss, Format: render.FormatPDF,
Params: export.Params{From: "2026-01-01", To: "2026-12-31", AnalyticAccountIDs: []uuid.UUID{ax.HQ.ID, ax.ProjA.ID}}})
require.ErrorIs(t, err, reporting.ErrAnalyticAxesMixed)
_, err = w.create(t, export.Request{Report: export.ReportTrialBalance, Format: "docx", Params: tbFY2026.Params})
require.ErrorIs(t, err, export.ErrUnknownFormat)
}
// TestAGeneralLedgerOverItsCapIsRefused (Ruling R12): Bank has 8 lines in FY2026.
func TestAGeneralLedgerOverItsCapIsRefused(t *testing.T) {
w := newWorld(t)
old := export.MaxLines[render.FormatCSV]
t.Cleanup(func() { export.MaxLines[render.FormatCSV] = old })
req := export.Request{Report: export.ReportGeneralLedger, Format: render.FormatCSV,
Params: export.Params{From: "2026-01-01", To: "2026-12-31", AccountID: &w.g.Books.Chart.Bank.ID}}
export.MaxLines[render.FormatCSV] = 8
_, err := w.create(t, req)
require.NoError(t, err, "PRESENCE: at the cap is accepted")
export.MaxLines[render.FormatCSV] = 7
_, err = w.create(t, req)
var tl *export.TooLargeError
require.ErrorAs(t, err, &tl)
require.EqualValues(t, 8, tl.Lines)
require.EqualValues(t, 7, tl.Max)
}
func TestListIsNewestFirstAndOrgScoped(t *testing.T) {
w := newWorld(t)
a, err := w.create(t, tbFY2026)
require.NoError(t, err)
w.e.Clock = clock.Fixed(accountingtest.T0.Add(time.Minute))
b, err := w.create(t, export.Request{Report: export.ReportBalanceSheet, Format: render.FormatXLSX, Params: export.Params{AsOf: "2026-12-31"}})
require.NoError(t, err)
var got []export.Export
require.NoError(t, w.e.DB.InReadOnlyTx(w.e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = export.List(ctx, tx, 0, 0)
return err
}))
require.Equal(t, []uuid.UUID{b.ID, a.ID}, []uuid.UUID{got[0].ID, got[1].ID})
other := w.e.Another(t, "Other Org")
require.NoError(t, other.DB.InReadOnlyTx(other.Ctx, func(ctx context.Context, tx pgx.Tx) error {
got, err := export.List(ctx, tx, 0, 0)
require.Empty(t, got)
_, err2 := export.Get(ctx, tx, a.ID)
require.ErrorIs(t, err2, export.ErrNotFound)
return err
}))
}
func (w world) get(t *testing.T, id uuid.UUID) export.Export {
t.Helper()
var x export.Export
require.NoError(t, w.e.DB.InReadOnlyTx(w.e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = export.Get(ctx, tx, id)
return err
}))
return x
}
  • Step 2: Run and watch fail. go test ./internal/platform/reporting/export/ -count=1 → FAIL (undefined).

  • Step 3: Implement params.go.

const dateLayout = "2006-01-02"
// allowed lists, per report, which fields a request may carry (Rulings R8, R9).
var allowed = map[string]map[string]bool{
ReportTrialBalance: {"from": true, "to": true, "account_id": true, "exclude_closing": true},
ReportProfitAndLoss: {"from": true, "to": true, "account_id": true, "analytic_account_ids": true},
ReportBalanceSheet: {"as_of": true},
ReportGeneralLedger: {"from": true, "to": true, "account_id": true, "analytic_account_ids": true},
}
func (p Params) present() map[string]bool {
return map[string]bool{
"from": p.From != "", "to": p.To != "", "as_of": p.AsOf != "", "account_id": p.AccountID != nil,
"analytic_account_ids": len(p.AnalyticAccountIDs) > 0, "exclude_closing": p.ExcludeClosing,
}
}
func (p Params) Validate(report string) error {
ok, known := allowed[report]
if !known {
return ErrUnknownReport
}
for _, f := range []string{"from", "to", "as_of", "account_id", "analytic_account_ids", "exclude_closing"} {
if p.present()[f] && !ok[f] {
return &InvalidParamsError{Field: f, Reason: "not taken by " + report}
}
}
if report == ReportBalanceSheet {
if _, err := time.Parse(dateLayout, p.AsOf); err != nil {
return &InvalidParamsError{Field: "as_of", Reason: "a date YYYY-MM-DD is required"}
}
return nil
}
from, err := time.Parse(dateLayout, p.From)
if err != nil {
return &InvalidParamsError{Field: "from", Reason: "a date YYYY-MM-DD is required"}
}
to, err := time.Parse(dateLayout, p.To)
if err != nil {
return &InvalidParamsError{Field: "to", Reason: "a date YYYY-MM-DD is required"}
}
if from.After(to) {
return &InvalidParamsError{Field: "to", Reason: "must be on or after from"}
}
return nil
}
func (p Params) Range() reporting.Range {
from, _ := time.Parse(dateLayout, p.From)
to, _ := time.Parse(dateLayout, p.To)
return reporting.Range{From: from, To: to}
}
func (p Params) AsOfDate() time.Time { d, _ := time.Parse(dateLayout, p.AsOf); return d }
func (p Params) Scope() reporting.Scope {
return reporting.Scope{AccountID: p.AccountID, AnalyticAccountIDs: p.AnalyticAccountIDs}
}

errors.go: the three sentinels; InvalidParamsError.Error() = "params." + Field + ": " + Reason; TooLargeError.Error() = fmt.Sprintf("export: the general ledger has %d lines for these parameters; a %s export holds at most %d", Lines, Format, Max).

  • Step 4: Implement export.go.
type JobArgs struct {
OrgID uuid.UUID `json:"org_id"`
ExportID uuid.UUID `json:"export_id"`
}
func (JobArgs) Kind() string { return "report_export" }
// InsertOpts: River retries a failed attempt twice (Ruling R16); no
// UniqueOpts, so River takes no advisory lock.
func (JobArgs) InsertOpts() river.InsertOpts {
return river.InsertOpts{Queue: jobs.QueueReports, MaxAttempts: 3}
}
func (x Export) Filename() string {
if x.Report == ReportBalanceSheet {
return fmt.Sprintf("%s_%s.%s", x.Report, x.Params.AsOf, x.Format)
}
return fmt.Sprintf("%s_%s_%s.%s", x.Report, x.Params.From, x.Params.To, x.Format)
}
const exportColumns = `x.id, x.report, x.format, x.params, x.status, x.requested_by, x.requested_at, x.river_job_id,
x.finished_at, x.object_key, x.content_type, x.byte_size, x.sha256, x.row_count, x.failure_code`
func scanExport(row pgx.Row) (Export, error) {
var x Export
var params []byte
var format string
err := row.Scan(&x.ID, &x.Report, &format, &params, &x.Status, &x.RequestedBy, &x.RequestedAt, &x.RiverJobID,
&x.FinishedAt, &x.ObjectKey, &x.ContentType, &x.ByteSize, &x.SHA256, &x.RowCount, &x.FailureCode)
if err != nil {
return Export{}, err
}
x.Format = render.Format(format)
x.RequestedAt = x.RequestedAt.UTC()
if err := json.Unmarshal(params, &x.Params); err != nil {
return Export{}, fmt.Errorf("export: params: %w", err)
}
return x, nil
}
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, ins jobs.Inserter, req Request, by uuid.UUID) (Export, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Export{}, fmt.Errorf("export: %w", tenancy.ErrNoOrg)
}
if !req.Format.Valid() {
return Export{}, ErrUnknownFormat
}
if err := req.Params.Validate(req.Report); err != nil {
return Export{}, err
}
if err := reporting.CheckScope(ctx, tx, req.Params.Scope()); err != nil {
return Export{}, err
}
if req.Report == ReportGeneralLedger {
n, err := reporting.CountGeneralLedger(ctx, tx, reporting.GeneralLedgerParams{Range: req.Params.Range(), Scope: req.Params.Scope()})
if err != nil {
return Export{}, err
}
if max := MaxLines[req.Format]; n > max {
return Export{}, &TooLargeError{Lines: n, Max: max, Format: req.Format}
}
}
x := Export{ID: uuid.New(), Report: req.Report, Format: req.Format, Params: req.Params,
Status: StatusQueued, RequestedBy: by, RequestedAt: c.Now().UTC()}
job, err := ins.InsertTx(ctx, tx, JobArgs{OrgID: orgID, ExportID: x.ID}, nil)
if err != nil {
return Export{}, fmt.Errorf("export: enqueue: %w", err)
}
x.RiverJobID = job.Job.ID
params, err := json.Marshal(x.Params)
if err != nil {
return Export{}, fmt.Errorf("export: params: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO report_export (id, org_id, report, format, params, requested_by, requested_at, river_job_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
x.ID, orgID, x.Report, string(x.Format), params, by, x.RequestedAt, x.RiverJobID); err != nil {
return Export{}, fmt.Errorf("export: insert: %w", err)
}
// Audit last (wave-2 §3 lock order): who, which report, which parameters (Ruling R19).
if err := audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "report_export", EntityID: x.ID, Action: "request", ActorID: &by,
After: map[string]any{"report": x.Report, "format": x.Format, "params": x.Params, "river_job_id": x.RiverJobID},
}); err != nil {
return Export{}, fmt.Errorf("export: audit: %w", err)
}
return x, nil
}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Export, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return Export{}, fmt.Errorf("export: %w", tenancy.ErrNoOrg)
}
x, err := scanExport(tx.QueryRow(ctx, `SELECT `+exportColumns+` FROM report_export x WHERE x.org_id = $1 AND x.id = $2`, orgID, id))
if errors.Is(err, pgx.ErrNoRows) {
return Export{}, ErrNotFound
}
return x, err
}
func List(ctx context.Context, tx pgx.Tx, limit, offset int) ([]Export, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("export: %w", tenancy.ErrNoOrg)
}
switch {
case limit <= 0:
limit = 50
case limit > 100:
limit = 100
}
if offset < 0 {
offset = 0
}
rows, err := tx.Query(ctx, `SELECT `+exportColumns+` FROM report_export x WHERE x.org_id = $1
ORDER BY x.requested_at DESC, x.id DESC LIMIT $2 OFFSET $3`, orgID, limit, offset)
if err != nil {
return nil, fmt.Errorf("export: list: %w", err)
}
defer rows.Close()
out := []Export{}
for rows.Next() {
x, err := scanExport(rows)
if err != nil {
return nil, fmt.Errorf("export: list: %w", err)
}
out = append(out, x)
}
return out, rows.Err()
}

Also add, in export.go, the compile-time proof of Ruling R21 in a test file store_test.go: var _ export.Store = (*documentstest.MemStore)(nil) and var _ export.Store = (*s3store.Store)(nil).

  • Step 5: Run. go test ./internal/platform/reporting/... ./internal/kernel/layering/ -count=1 → PASS. (TestReportingNeverWrites skips export/ by design; the tenant-predicate guard covers its SQL.)

  • Step 6: Commit

Terminal window
git add internal/platform/reporting/export
git commit -m "Request report exports: validated, enqueued in the same transaction, audited
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 13: export — the job: project, render, store, finish

Section titled “Task 13: export — the job: project, render, store, finish”

Files:

  • Create: internal/platform/reporting/export/project.go, run.go, worker.go
  • Test: internal/platform/reporting/export/run_test.go

Interfaces:

  • Consumes: Tasks 7–9’s TrialBalance, ProfitAndLoss, BalanceSheet, GeneralLedger, GeneralLedgerHeader, CountGeneralLedger; Task 10–11’s render.*; Task 12’s Export, Get, JobArgs, MaxLines, Store, TooLargeError; db.InReadOnlyTx, db.InTx; company.Get; chart.GetAccount; documentstest.MemStore (tests).

  • Produces:

    type RunDeps struct {
    DB *db.DB
    Store Store
    Clock clock.Clock
    Logger *slog.Logger // nil: slog.Default()
    }
    // Run works one attempt of one export job (Rulings R17, R18).
    func Run(ctx context.Context, d RunDeps, args JobArgs, attempt, maxAttempts int) error
    type Worker struct {
    river.WorkerDefaults[JobArgs]
    Deps RunDeps
    }
    func (w *Worker) Work(ctx context.Context, job *river.Job[JobArgs]) error
    func (w *Worker) Timeout(*river.Job[JobArgs]) time.Duration // 15 minutes
    // ObjectKey is where an export's file lives (Ruling R20).
    func ObjectKey(orgID uuid.UUID, x Export) string // "reports/<org>/<export>/<filename>"
  • Step 1: Write the failing tests — run_test.go (reuses world, newWorld, create, get, tbFY2026 from Task 12’s test file):

func (w world) deps(store export.Store) export.RunDeps {
return export.RunDeps{DB: w.e.DB, Store: store, Clock: w.e.Clock}
}
func (w world) run(t *testing.T, store export.Store, x export.Export, attempt int) error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
return export.Run(ctx, w.deps(store), export.JobArgs{OrgID: w.e.Org, ExportID: x.ID}, attempt, 3)
}
func (w world) audits(t *testing.T, id uuid.UUID, action string) (n int, actorNull bool) {
t.Helper()
require.NoError(t, w.e.H.Admin.QueryRow(context.Background(),
`SELECT count(*), bool_and(actor_id IS NULL) FROM audit_log
WHERE org_id = $1 AND entity_kind = 'report_export' AND entity_id = $2 AND action = $3`,
w.e.Org, id, action).Scan(&n, &actorNull))
return n, actorNull
}
func TestAnExportRunsToAFileWithItsHash(t *testing.T) {
w := newWorld(t)
store := documentstest.NewMemStore()
x, err := w.create(t, tbFY2026)
require.NoError(t, err)
require.NoError(t, w.run(t, store, x, 1))
got := w.get(t, x.ID)
require.Equal(t, export.StatusSucceeded, got.Status)
require.Equal(t, export.ObjectKey(w.e.Org, got), *got.ObjectKey)
body, ok := store.Object(*got.ObjectKey)
require.True(t, ok)
sum := sha256.Sum256(body)
require.Equal(t, sum[:], got.SHA256)
require.EqualValues(t, len(body), *got.ByteSize)
require.EqualValues(t, 8, *got.RowCount, "eight accounts moved in FY2026")
csv := string(body)
require.Contains(t, csv, "1520,Bank,0.0000,0.0000,15099.9900,2264.9900,12835.0000,0.0000\r\n")
require.Contains(t, csv, "Total,,0.0000,0.0000,22665.4800,22665.4800,13034.5000,13034.5000\r\n")
require.Contains(t, csv, "Organization,Env Org\r\n")
require.Contains(t, csv, "Export,"+x.ID.String()+"\r\n")
n, actorNull := w.audits(t, x.ID, "complete")
require.Equal(t, 1, n)
require.True(t, actorNull, "the system completed it (Ruling R19)")
}
func TestRunningTwiceDoesNothingTheSecondTime(t *testing.T) {
w := newWorld(t)
store := documentstest.NewMemStore()
x, err := w.create(t, tbFY2026)
require.NoError(t, err)
require.NoError(t, w.run(t, store, x, 1))
require.NoError(t, w.run(t, store, x, 2))
n, _ := w.audits(t, x.ID, "complete")
require.Equal(t, 1, n)
}
// TestEveryReportRendersInEveryFormat: the projection covers all four
// reports; XLSX opens in excelize, PDF is a PDF.
func TestEveryReportRendersInEveryFormat(t *testing.T) {
w := newWorld(t)
store := documentstest.NewMemStore()
fy := export.Params{From: "2026-01-01", To: "2026-12-31"}
for _, r := range []export.Request{
{Report: export.ReportTrialBalance, Params: fy},
{Report: export.ReportProfitAndLoss, Params: export.Params{From: "2026-01-01", To: "2026-12-31", AnalyticAccountIDs: []uuid.UUID{w.g.Books.Axes.HQ.ID}}},
{Report: export.ReportBalanceSheet, Params: export.Params{AsOf: "2026-12-31"}},
{Report: export.ReportGeneralLedger, Params: fy},
} {
for _, f := range []render.Format{render.FormatCSV, render.FormatXLSX, render.FormatPDF} {
r.Format = f
x, err := w.create(t, r)
require.NoError(t, err, "%s %s", r.Report, f)
require.NoError(t, w.run(t, store, x, 1), "%s %s", r.Report, f)
got := w.get(t, x.ID)
require.Equal(t, export.StatusSucceeded, got.Status, "%s %s", r.Report, f)
body, _ := store.Object(*got.ObjectKey)
switch f {
case render.FormatXLSX:
_, err := excelize.OpenReader(bytes.NewReader(body))
require.NoError(t, err)
case render.FormatPDF:
require.True(t, bytes.HasPrefix(body, []byte("%PDF-")))
}
}
}
}
func TestTheProfitAndLossExportMatchesTheReport(t *testing.T) {
w := newWorld(t)
store := documentstest.NewMemStore()
x, err := w.create(t, export.Request{Report: export.ReportProfitAndLoss, Format: render.FormatCSV,
Params: export.Params{From: "2026-01-01", To: "2026-12-31"}})
require.NoError(t, err)
require.NoError(t, w.run(t, store, x, 1))
body, _ := store.Object(*w.get(t, x.ID).ObjectKey)
csv := string(body)
require.Contains(t, csv, "Income,0,4000,Income,5000.0000\r\n")
require.Contains(t, csv, "Expenses,2,6510,Stationery,45.0000\r\n")
require.Contains(t, csv, "Net profit,,,,3034.5000\r\n", "a closed year still shows its result (Ruling R5)")
}
func TestTheGeneralLedgerExportCarriesOpeningsAndClosings(t *testing.T) {
w := newWorld(t)
store := documentstest.NewMemStore()
x, err := w.create(t, export.Request{Report: export.ReportGeneralLedger, Format: render.FormatCSV,
Params: export.Params{From: "2027-01-01", To: "2027-12-31", AccountID: &w.g.Books.Chart.Bank.ID}})
require.NoError(t, err)
require.NoError(t, w.run(t, store, x, 1))
got := w.get(t, x.ID)
body, _ := store.Object(*got.ObjectKey)
csv := string(body)
require.Contains(t, csv, "1520,Bank,,,,Opening balance,,,,12835.0000,")
require.Contains(t, csv, "1520,Bank,,,,Closing balance,,,,13435.0000,")
require.EqualValues(t, 2, *got.RowCount, "line rows only")
}
func TestAnExportThatGrewPastItsCapFails(t *testing.T) {
w := newWorld(t)
x, err := w.create(t, export.Request{Report: export.ReportGeneralLedger, Format: render.FormatCSV,
Params: export.Params{From: "2026-01-01", To: "2026-12-31"}})
require.NoError(t, err)
old := export.MaxLines[render.FormatCSV]
t.Cleanup(func() { export.MaxLines[render.FormatCSV] = old })
export.MaxLines[render.FormatCSV] = 1
require.NoError(t, w.run(t, documentstest.NewMemStore(), x, 1), "a permanent failure completes the job")
got := w.get(t, x.ID)
require.Equal(t, export.StatusFailed, got.Status)
require.Equal(t, export.FailureTooLarge, *got.FailureCode)
n, _ := w.audits(t, x.ID, "fail")
require.Equal(t, 1, n)
}
// TestAJobForAnotherOrgDoesNothing (Ruling R17): PRESENCE — under its own
// org the same export completes.
func TestAJobForAnotherOrgDoesNothing(t *testing.T) {
w := newWorld(t)
other := w.e.Another(t, "Other Org")
x, err := w.create(t, tbFY2026)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
require.NoError(t, export.Run(ctx, w.deps(documentstest.NewMemStore()), export.JobArgs{OrgID: other.Org, ExportID: x.ID}, 1, 3))
require.Equal(t, export.StatusQueued, w.get(t, x.ID).Status, "invisible under the other org")
require.NoError(t, w.run(t, documentstest.NewMemStore(), x, 1))
require.Equal(t, export.StatusSucceeded, w.get(t, x.ID).Status)
}
type failingStore struct{ export.Store }
func (failingStore) Put(context.Context, string, string, []byte) error { return errors.New("store down") }
func TestTheLastFailedAttemptMarksTheExportFailed(t *testing.T) {
w := newWorld(t)
x, err := w.create(t, tbFY2026)
require.NoError(t, err)
require.Error(t, w.run(t, failingStore{}, x, 1), "a transient error goes back to River")
require.Equal(t, export.StatusQueued, w.get(t, x.ID).Status, "and the export waits for the retry")
require.Error(t, w.run(t, failingStore{}, x, 3))
got := w.get(t, x.ID)
require.Equal(t, export.StatusFailed, got.Status)
require.Equal(t, export.FailureInternal, *got.FailureCode)
}
  • Step 2: Run and watch fail. go test ./internal/platform/reporting/export/ -run 'Run|Export|Job|Attempt|Report' -count=1 → FAIL.

  • Step 3: Implement project.go. One function per report; each builds render.Meta and columns, calls Begin, writes rows, returns the data-row count. Common pieces:

var reportTitles = map[string]string{
ReportTrialBalance: "Trial balance", ReportProfitAndLoss: "Profit and loss",
ReportBalanceSheet: "Balance sheet", ReportGeneralLedger: "General ledger",
}
// meta is what every export states above its table (Ruling R24): who the
// figures belong to, which figures, how final they are, and which export.
func meta(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, currency string, provisional bool) (render.Meta, error) {
p, err := company.Get(ctx, tx)
if err != nil {
return render.Meta{}, err
}
f := [][2]string{{"Organization", p.LegalName}, {"Registration no.", p.RegistrationNo}}
if x.Report == ReportBalanceSheet {
f = append(f, [2]string{"As of", x.Params.AsOf})
} else {
f = append(f, [2]string{"From", x.Params.From}, [2]string{"To", x.Params.To})
}
if x.Params.AccountID != nil {
a, err := chart.GetAccount(ctx, tx, *x.Params.AccountID)
if err != nil {
return render.Meta{}, err
}
f = append(f, [2]string{"Account", a.Code + " " + a.Name + " and everything under it"})
}
if len(x.Params.AnalyticAccountIDs) > 0 {
codes, err := analyticCodes(ctx, tx, x.Params.AnalyticAccountIDs)
if err != nil {
return render.Meta{}, err
}
f = append(f, [2]string{"Analytic accounts", strings.Join(codes, ", ") + " (stored shares)"})
}
if x.Params.ExcludeClosing {
f = append(f, [2]string{"Year-end closing entries", "excluded"})
}
generated := c.Now().UTC().Format(time.RFC3339)
prov := "no"
if provisional {
prov = "yes: a fiscal period in range is still open"
}
f = append(f,
[2]string{"Currency", currency},
[2]string{"Provisional", prov},
[2]string{"Source", "Posted journal entries, base currency"},
[2]string{"Generated at", generated},
[2]string{"Export", x.ID.String()})
title := reportTitles[x.Report]
return render.Meta{Title: title, Fields: f, Footer: title + " · Export " + x.ID.String() + " · " + generated,
Landscape: x.Report == ReportTrialBalance || x.Report == ReportGeneralLedger}, nil
}
const analyticCodesSQL = `SELECT aa.code FROM analytic_account aa WHERE aa.org_id = $1 AND aa.id = ANY($2) ORDER BY aa.code`

(analyticCodes runs analyticCodesSQL with the org from the context.)

Trial balance:

func projectTrialBalance(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error) {
r, err := reporting.TrialBalance(ctx, tx, reporting.TrialBalanceParams{
Range: x.Params.Range(), AccountID: x.Params.AccountID, ExcludeClosing: x.Params.ExcludeClosing})
if err != nil {
return 0, err
}
m, err := meta(ctx, tx, c, x, r.Currency, r.Provisional)
if err != nil {
return 0, err
}
moneyCol := func(t string) render.Column { return render.Column{Title: t, Numeric: true, Weight: 10} }
if err := w.Begin(m, []render.Column{{Title: "Code", Weight: 5}, {Title: "Account", Weight: 15},
moneyCol("Opening debit"), moneyCol("Opening credit"), moneyCol("Debit"), moneyCol("Credit"),
moneyCol("Closing debit"), moneyCol("Closing credit")}); err != nil {
return 0, err
}
for _, row := range r.Rows {
if err := w.Row([]render.Cell{render.Text(row.Account.Code), render.Text(row.Account.Name),
render.Money(row.OpeningDebit), render.Money(row.OpeningCredit), render.Money(row.Debit),
render.Money(row.Credit), render.Money(row.ClosingDebit), render.Money(row.ClosingCredit)}); err != nil {
return 0, err
}
}
t := r.Totals
b := func(a money.Amount) render.Cell { return render.Bold(render.Money(a)) }
return int64(len(r.Rows)), w.Row([]render.Cell{render.Bold(render.Text("Total")), {},
b(t.OpeningDebit), b(t.OpeningCredit), b(t.Debit), b(t.Credit), b(t.ClosingDebit), b(t.ClosingCredit)})
}

Profit and loss and balance sheet share a statement projection. Columns: Section (weight 8), Level (3, OmitInPDF), Code (5), Account (20), Amount (numeric, 10). A StatementRow becomes [section, strconv.Itoa(depth), code, name, amount], header rows bold; in PDF the account name is indented by two spaces per depth level (prefix the name with strings.Repeat(" ", depth) in the PDF only — pass the indented name when x.Format == render.FormatPDF). Totals are rows with empty level/code and the label in Section:

  • P&L: every income row (section “Income”), then ["Total income","","","",TotalIncome] bold; every expense row (section “Expenses”), ["Total expenses",…,TotalExpenses]; ["Net profit",…,NetProfit] bold. The data-row count is the number of statement rows.
  • Balance sheet: sections “Assets” (+ “Total assets”), “Liabilities” (+ “Total liabilities”), “Equity” rows, then ["Equity","","","Current year earnings",CurrentYearEarnings] and ["Equity","","","Prior years' earnings not yet closed",PriorUnclosedEarnings] (both always written, even when zero, so an auditor sees them), ["Total equity",…,TotalEquity], ["Total liabilities and equity",…,TotalLiabilities+TotalEquity]; if !Balanced, a final bold ["OUT OF BALANCE — run reconciliation","","","",TotalAssets−(TotalLiabilities+TotalEquity)] (Ruling R33).

General ledger (streams; nothing held but the current account’s closing):

var glColumns = []render.Column{
{Title: "Account code", Weight: 5}, {Title: "Account", Weight: 12, OmitInPDF: true},
{Title: "Date", Weight: 6}, {Title: "Entry no.", Weight: 8}, {Title: "Journal", Weight: 4},
{Title: "Description", Weight: 22}, {Title: "Memo", Weight: 12, OmitInPDF: true},
{Title: "Debit", Numeric: true, Weight: 10}, {Title: "Credit", Numeric: true, Weight: 10},
{Title: "Balance", Numeric: true, Weight: 10}, {Title: "Currency", Weight: 3, OmitInPDF: true},
{Title: "Transaction debit", Numeric: true, Weight: 10, OmitInPDF: true},
{Title: "Transaction credit", Numeric: true, Weight: 10, OmitInPDF: true},
{Title: "Source kind", Weight: 6, OmitInPDF: true}, {Title: "Source id", Weight: 12, OmitInPDF: true},
}
type glExport struct {
w render.Writer
cur *reporting.GLAccount
balance money.Amount
lines int64
}
func (g *glExport) balanceRow(label string, a reporting.AccountRef, bal money.Amount) error {
return g.w.Row([]render.Cell{render.Bold(render.Text(a.Code)), render.Bold(render.Text(a.Name)), {}, {}, {},
render.Bold(render.Text(label)), {}, {}, {}, render.Bold(render.Money(bal))})
}
func (g *glExport) Account(a reporting.GLAccount) error {
if err := g.close(); err != nil {
return err
}
g.cur, g.balance = &a, a.Opening
return g.balanceRow("Opening balance", a.Account, a.Opening)
}
func (g *glExport) Line(l reporting.GLLine) error {
g.balance = l.Balance
g.lines++
opt := func(a *money.Amount) render.Cell {
if a == nil {
return render.Cell{}
}
return render.Money(*a)
}
str := func(s *string) string {
if s == nil {
return ""
}
return *s
}
src := ""
if l.SourceID != nil {
src = l.SourceID.String()
}
return g.w.Row([]render.Cell{render.Text(g.cur.Account.Code), render.Text(g.cur.Account.Name),
render.Text(l.EntryDate.Format("2006-01-02")), render.Text(l.EntryNo), render.Text(l.JournalCode),
render.Text(l.Description), render.Text(l.Memo), render.Money(l.Debit), render.Money(l.Credit),
render.Money(l.Balance), render.Text(l.Currency), opt(l.TxDebit), opt(l.TxCredit),
render.Text(str(l.SourceKind)), render.Text(src)})
}
// close writes the current account's closing row, if an account is open.
func (g *glExport) close() error {
if g.cur == nil {
return nil
}
return g.balanceRow("Closing balance", g.cur.Account, g.balance)
}
func projectGeneralLedger(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error) {
p := reporting.GeneralLedgerParams{Range: x.Params.Range(), Scope: x.Params.Scope()}
n, err := reporting.CountGeneralLedger(ctx, tx, p)
if err != nil {
return 0, err
}
if max := MaxLines[x.Format]; n > max {
return 0, &TooLargeError{Lines: n, Max: max, Format: x.Format}
}
h, err := reporting.GeneralLedgerHeader(ctx, tx, p)
if err != nil {
return 0, err
}
m, err := meta(ctx, tx, c, x, h.Currency, h.Provisional)
if err != nil {
return 0, err
}
if err := w.Begin(m, glColumns); err != nil {
return 0, err
}
g := &glExport{w: w}
if _, err := reporting.GeneralLedger(ctx, tx, p, nil, 0, g); err != nil {
return 0, err
}
return g.lines, g.close()
}
func project(ctx context.Context, tx pgx.Tx, c clock.Clock, x Export, w render.Writer) (int64, error) {
switch x.Report {
case ReportTrialBalance:
return projectTrialBalance(ctx, tx, c, x, w)
case ReportProfitAndLoss:
return projectProfitAndLoss(ctx, tx, c, x, w)
case ReportBalanceSheet:
return projectBalanceSheet(ctx, tx, c, x, w)
case ReportGeneralLedger:
return projectGeneralLedger(ctx, tx, c, x, w)
}
return 0, ErrUnknownReport
}
  • Step 4: Implement run.go and worker.go.
func ObjectKey(orgID uuid.UUID, x Export) string {
return fmt.Sprintf("reports/%s/%s/%s", orgID, x.ID, x.Filename())
}
// permanent reports whether err will fail every retry: the parameters no
// longer resolve, or the ledger outgrew the cap.
func permanent(err error) (code string, ok bool) {
var tl *TooLargeError
switch {
case errors.As(err, &tl):
return FailureTooLarge, true
case errors.Is(err, reporting.ErrAccountNotFound), errors.Is(err, reporting.ErrAnalyticAccountNotFound),
errors.Is(err, reporting.ErrAnalyticAxesMixed), errors.Is(err, reporting.ErrInvalidRange),
errors.Is(err, ErrUnknownReport):
return FailureInvalidParameters, true
}
return "", false
}
func Run(ctx context.Context, d RunDeps, args JobArgs, attempt, maxAttempts int) error {
log := d.Logger
if log == nil {
log = slog.Default()
}
ctx = tenancy.WithOrg(ctx, args.OrgID)
log = log.With("org_id", args.OrgID, "export_id", args.ExportID, "attempt", attempt)
var x Export
err := d.DB.InReadOnlyTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = Get(ctx, tx, args.ExportID)
return err
})
if errors.Is(err, ErrNotFound) {
log.Warn("report export: no such export under this organization; nothing to do")
return nil // Ruling R17
}
if err != nil {
return transient(ctx, d, log, args, attempt, maxAttempts, err)
}
if x.Status != StatusQueued {
return nil
}
// One read-only snapshot for the whole file (Ruling R4).
var body []byte
var rows int64
var contentType string
err = d.DB.InReadOnlyTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
w, ct, _, err := render.New(x.Format)
if err != nil {
return err
}
contentType = ct
if rows, err = project(ctx, tx, d.Clock, x, w); err != nil {
return err
}
body, err = w.Finish()
return err
})
if code, ok := permanent(err); ok {
log.Info("report export failed permanently", "code", code, "error", err)
return finish(ctx, d, args.ExportID, outcome{failure: code})
}
if err != nil {
return transient(ctx, d, log, args, attempt, maxAttempts, err)
}
// Object I/O between transactions, never inside one.
key := ObjectKey(args.OrgID, x)
if err := d.Store.Put(ctx, key, contentType, body); err != nil {
return transient(ctx, d, log, args, attempt, maxAttempts, err)
}
sum := sha256.Sum256(body)
return finish(ctx, d, args.ExportID, outcome{key: key, contentType: contentType, size: int64(len(body)), sha: sum[:], rows: rows})
}
// transient hands err back to River for a retry — after marking the export
// failed when this was the last attempt (Ruling R18).
func transient(ctx context.Context, d RunDeps, log *slog.Logger, args JobArgs, attempt, maxAttempts int, err error) error {
log.Error("report export attempt failed", "error", err)
if attempt >= maxAttempts {
if ferr := finish(ctx, d, args.ExportID, outcome{failure: FailureInternal}); ferr != nil {
log.Error("report export: could not record the final failure", "error", ferr)
}
}
return err
}
type outcome struct {
failure string // "" = success
key, contentType string
size, rows int64
sha []byte
}
// finish records the outcome once: lock the row, re-check it is queued
// (another attempt may have finished), update, audit last.
func finish(ctx context.Context, d RunDeps, id uuid.UUID, o outcome) error {
return d.DB.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
orgID, _ := tenancy.OrgFromContext(ctx)
var status string
if err := tx.QueryRow(ctx, `SELECT x.status FROM report_export x WHERE x.org_id = $1 AND x.id = $2 FOR UPDATE`,
orgID, id).Scan(&status); err != nil {
return fmt.Errorf("export: finish: %w", err)
}
if status != StatusQueued {
return nil
}
now := d.Clock.Now().UTC()
entry := audit.Entry{EntityKind: "report_export", EntityID: id} // ActorID nil: the system (Ruling R19)
if o.failure != "" {
if _, err := tx.Exec(ctx, `UPDATE report_export SET status = 'failed', finished_at = $3, failure_code = $4
WHERE org_id = $1 AND id = $2`, orgID, id, now, o.failure); err != nil {
return fmt.Errorf("export: finish: %w", err)
}
entry.Action, entry.After = "fail", map[string]any{"failure_code": o.failure}
} else {
if _, err := tx.Exec(ctx, `UPDATE report_export SET status = 'succeeded', finished_at = $3, object_key = $4,
content_type = $5, byte_size = $6, sha256 = $7, row_count = $8 WHERE org_id = $1 AND id = $2`,
orgID, id, now, o.key, o.contentType, o.size, o.sha, o.rows); err != nil {
return fmt.Errorf("export: finish: %w", err)
}
entry.Action, entry.After = "complete", map[string]any{"object_key": o.key, "byte_size": o.size,
"sha256": hex.EncodeToString(o.sha), "row_count": o.rows}
}
return audit.Write(ctx, tx, d.Clock, entry)
})
}

worker.go:

type Worker struct {
river.WorkerDefaults[JobArgs]
Deps RunDeps
}
func (w *Worker) Work(ctx context.Context, job *river.Job[JobArgs]) error {
return Run(ctx, w.Deps, job.Args, job.Attempt, job.MaxAttempts)
}
func (w *Worker) Timeout(*river.Job[JobArgs]) time.Duration { return 15 * time.Minute }
  • Step 5: Run. go test ./internal/platform/reporting/... ./internal/kernel/layering/ -count=1 → PASS.

  • Step 6: Sabotage once. (a) Delete the if status != StatusQueued { return nil } check in finish → TestRunningTwiceDoesNothingTheSecondTime red (the second run’s UPDATE hits report_export_immutable). (b) Delete the permanent branch in Run → TestAnExportThatGrewPastItsCapFails red (the job returns an error and the export stays queued). Restore; quote both.

  • Step 7: Commit

Terminal window
git add internal/platform/reporting/export
git commit -m "Work report exports: one snapshot, rendered, stored with its hash, finished once
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 14: modules/reports — composition, permissions, the four report routes

Section titled “Task 14: modules/reports — composition, permissions, the four report routes”

Runs alone: it edits every shared file.

Files:

  • Create: internal/modules/reports/permissions.go, routes.go, http.go, report_handlers.go, errors.go
  • Test: internal/modules/reports/env_test.go, report_handlers_test.go, routes_test.go
  • Modify: constants.yaml (append reports.read, reports.export) → make generate
  • Modify: api/openapi.yaml (append four paths and their schemas) → make generate
  • Modify: internal/app/app.go, internal/app/routes_test.go (composedModules), cmd/api/main.go, cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/migrate/wiring_test.go, cmd/orgadmin/wiring_test.go

Interfaces:

  • Consumes: reporting.* (Tasks 6–9); export.Store (Task 12); jobs.Inserter, jobs.NewInserter (Task 3); config.ObjectStoreFromEnv, s3store.New, documentstest.NewMemStore (Task 4); db.InReadOnlyTx (Task 1); i18n.Load; httpx.Protected; member.RegisterPermission, member.Permissions.
  • Produces:
    package reports // internal/modules/reports
    const (
    PermissionRead = "reports.read"
    PermissionExport = "reports.export"
    )
    type Deps struct {
    DB *db.DB
    Clock clock.Clock
    Jobs jobs.Inserter // Task 15's export requests
    Store export.Store // Task 15's downloads
    PresignTTL time.Duration // config.ObjectStore.PresignTTL
    }
    func Register(g *gin.RouterGroup, deps Deps)
    // http.go, shared with Task 15:
    func allowQuery(c *gin.Context, report string, allowed ...string) error // 422 naming the first other key
    func dateParam(c *gin.Context, name string, required bool) (*time.Time, error)
    func uuidParam(c *gin.Context, name string) (*uuid.UUID, error)
    func uuidsParam(c *gin.Context, name string) ([]uuid.UUID, error) // repeated key
    func accountLabels(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]string, error)
    type accountDTO struct{ /* account_id, code, name, label, type, depth */ }
    func toAccount(a reporting.AccountRef, labels map[uuid.UUID]string) accountDTO

Routes (every one through httpx.Protected; all run in h.deps.DB.InReadOnlyTx):

Method Path (under /v1) Permission operationId Query 200 body
GET /reports/trial-balance reports.read getTrialBalance from, to, account_id, exclude_closing TrialBalance
GET /reports/profit-and-loss reports.read getProfitAndLoss from, to, account_id, analytic_account_id (repeatable) ProfitAndLoss
GET /reports/balance-sheet reports.read getBalanceSheet as_of* BalanceSheet
GET /reports/general-ledger reports.read getGeneralLedger from, to, account_id, analytic_account_id (repeatable), cursor, limit (1–2000, default 500) GeneralLedger

Any other query key is 422 naming the key and the report (“the trial balance does not take analytic_account_id” — Rulings R8, R9). A malformed date, a from after to, an unknown account or analytic account, analytic accounts on two axes, a bad cursor and a limit outside 1–2000 are 422. Every list in a body is [], never null. Every amount is a Money string.

  • Step 1: Permissions and constants. permissions.go:
// The two permissions this module's routes are gated on (Ruling R26).
// Reading a report and exporting one are separate: an export writes a
// seven-year record and occupies the worker, a read does neither.
const (
PermissionRead = "reports.read"
PermissionExport = "reports.export"
)
func init() {
member.RegisterPermission(PermissionRead,
"Read the trial balance, profit and loss, balance sheet and general ledger, and download exports")
member.RegisterPermission(PermissionExport,
"Request CSV, XLSX and PDF exports of reports")
}

Append reports.read and reports.export to the end of constants.yaml’s permissions: list; make generate.

  • Step 2: Write the failing tests. env_test.go — the harness. Copy internal/modules/accounting/chart_handlers_test.go’s grant, mint, do, decode, discard and alwaysLive verbatim (they are small and a module does not import another module’s tests), and build:
type env struct {
acc *accountingtest.Env
g reportingtest.Golden
r *gin.Engine
signer *token.Signer
store *documentstest.MemStore
ins *river.Client[pgx.Tx]
reader, exporter, stranger uuid.UUID
readerTok, exporterTok, strangerTok string
}
func newEnv(t *testing.T) *env {
t.Helper()
gin.SetMode(gin.TestMode)
acc := accountingtest.New(t)
g := reportingtest.Seed(t, acc, reportingtest.Options{CloseFY2026: true})
signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")},
"k1", clock.Fixed(accountingtest.T0))
require.NoError(t, err)
ins, err := jobs.NewInserter(acc.DB.Pool())
require.NoError(t, err)
store := documentstest.NewMemStore()
r, api := httpx.NewRouter(httpx.Deps{DB: acc.DB, Logger: slog.New(slog.NewTextHandler(discard{}, nil)),
Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}})
reports.Register(api, reports.Deps{DB: acc.DB, Clock: acc.Clock, Jobs: ins, Store: store, PresignTTL: 5 * time.Minute})
e := &env{acc: acc, g: g, r: r, signer: signer, store: store, ins: ins}
e.reader, e.exporter, e.stranger = acc.SeedMember(t, "Reader"), acc.SeedMember(t, "Exporter"), acc.SeedMember(t, "Stranger")
e.grant(t, acc.Org, e.reader, reports.PermissionRead)
e.grant(t, acc.Org, e.exporter, reports.PermissionRead, reports.PermissionExport)
e.grant(t, acc.Org, e.stranger, "accounting.ledger.read") // reads the books, not the reports
e.readerTok, e.exporterTok, e.strangerTok = e.mint(t, acc.Org, e.reader), e.mint(t, acc.Org, e.exporter), e.mint(t, acc.Org, e.stranger)
return e
}
func (e *env) get(t *testing.T, path, tok string) *httptest.ResponseRecorder {
t.Helper()
return e.do(t, http.MethodGet, path, tok, nil)
}

report_handlers_test.go:

func TestReportsNeedReportsRead(t *testing.T) {
e := newEnv(t)
for _, path := range []string{
"/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31",
"/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31",
"/v1/reports/balance-sheet?as_of=2026-12-31",
"/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31",
} {
require.Equal(t, http.StatusOK, e.get(t, path, e.readerTok).Code, path) // PRESENCE
require.Equal(t, http.StatusForbidden, e.get(t, path, e.strangerTok).Code, path)
require.Equal(t, http.StatusUnauthorized, e.get(t, path, "").Code, path)
}
}
func TestTheTrialBalanceOverHTTP(t *testing.T) {
e := newEnv(t)
w := e.get(t, "/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31", e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
body := w.Body.String()
require.Contains(t, body, `"closing_debit":"12835.0000"`, "money is a string on the wire")
require.Contains(t, body, `"balanced":true`)
require.Contains(t, body, `"currency":"MYR"`)
require.Contains(t, body, `"label":"Bank"`)
w = e.get(t, "/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31&exclude_closing=true", e.readerTok)
require.Equal(t, http.StatusOK, w.Code)
require.NotContains(t, w.Body.String(), `"code":"3200"`, "pre-closing: retained earnings did not move")
}
func TestTheProfitAndLossOverHTTP(t *testing.T) {
e := newEnv(t)
w := e.get(t, "/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31", e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), `"net_profit":"3034.5000"`)
hq := e.g.Books.Axes.HQ.ID
w = e.get(t, "/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31&analytic_account_id="+hq.String(), e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), `"net_profit":"1590.0000"`)
require.Contains(t, w.Body.String(), `"filtered":true`)
}
func TestTheBalanceSheetOverHTTP(t *testing.T) {
e := newEnv(t)
w := e.get(t, "/v1/reports/balance-sheet?as_of=2026-12-31", e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
body := w.Body.String()
require.Contains(t, body, `"total_assets":"13034.5000"`)
require.Contains(t, body, `"liabilities":[]`, "an empty section is [], never null")
require.Contains(t, body, `"current_year_earnings":"0.0000"`)
require.Contains(t, body, `"balanced":true`)
}
func TestGeneralLedgerPagesOverHTTP(t *testing.T) {
e := newEnv(t)
bank := e.g.Books.Chart.Bank.ID.String()
type page struct {
Accounts []struct {
Code string `json:"code"`
Opening string `json:"opening"`
Closing string `json:"closing"`
Lines []struct {
Balance string `json:"balance"`
} `json:"lines"`
} `json:"accounts"`
NextCursor *string `json:"next_cursor"`
}
w := e.get(t, "/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31&limit=3&account_id="+bank, e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
p1 := decode[page](t, w)
require.Equal(t, "0.0000", p1.Accounts[0].Opening)
require.Equal(t, "13800.0000", p1.Accounts[0].Closing)
require.NotNil(t, p1.NextCursor)
w = e.get(t, "/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31&limit=3&account_id="+bank+
"&cursor="+url.QueryEscape(*p1.NextCursor), e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
p2 := decode[page](t, w)
require.Equal(t, "13800.0000", p2.Accounts[0].Opening)
require.Len(t, p2.Accounts[0].Lines, 3)
}
// TestFiltersAReportDoesNotTakeAreRefused (Rulings R8, R9): PRESENCE — the
// same request without the extra key is 200.
func TestFiltersAReportDoesNotTakeAreRefused(t *testing.T) {
e := newEnv(t)
hq := e.g.Books.Axes.HQ.ID.String()
bank := e.g.Books.Chart.Bank.ID.String()
for base, extra := range map[string]string{
"/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31": "&analytic_account_id=" + hq,
"/v1/reports/balance-sheet?as_of=2026-12-31": "&account_id=" + bank,
"/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31": "&exclude_closing=true",
"/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31": "&foo=bar",
} {
require.Equal(t, http.StatusOK, e.get(t, base, e.readerTok).Code, base)
w := e.get(t, base+extra, e.readerTok)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, base+extra)
require.Contains(t, w.Body.String(), "does not take")
}
}
func TestBadParametersAre422(t *testing.T) {
e := newEnv(t)
for _, path := range []string{
"/v1/reports/trial-balance?from=2026-12-31&to=2026-01-01",
"/v1/reports/trial-balance?from=2026-1-1&to=2026-12-31",
"/v1/reports/trial-balance?to=2026-12-31",
"/v1/reports/balance-sheet",
"/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31&account_id=" + uuid.NewString(),
"/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31&analytic_account_id=" +
e.g.Books.Axes.HQ.ID.String() + "&analytic_account_id=" + e.g.Books.Axes.ProjA.ID.String(),
"/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31&cursor=nope",
"/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31&limit=5000",
"/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31&exclude_closing=maybe",
} {
require.Equal(t, http.StatusUnprocessableEntity, e.get(t, path, e.readerTok).Code, path)
}
}
// TestReportsAreTheCallersOrgOnly: PRESENCE — the other org's reader sees its
// 777 sale; ABSENCE — ours does not.
func TestReportsAreTheCallersOrgOnly(t *testing.T) {
e := newEnv(t)
other := e.acc.Another(t, "Other Org")
og := reportingtest.Seed(t, other, reportingtest.Options{})
og.Post(t, "", "2026-06-01", "", reportingtest.Dr(og.Books.Chart.Bank, "777"), reportingtest.Cr(og.Books.Chart.Revenue, "777"))
otherReader := other.SeedMember(t, "Other Reader")
e.grant(t, other.Org, otherReader, reports.PermissionRead)
const path = "/v1/reports/profit-and-loss?from=2026-01-01&to=2026-12-31"
require.Contains(t, e.get(t, path, e.mint(t, other.Org, otherReader)).Body.String(), `"total_income":"5777.0000"`)
require.Contains(t, e.get(t, path, e.readerTok).Body.String(), `"total_income":"5000.0000"`)
}
// TestReportsWriteNothing: PRESENCE — the seed wrote audit rows, so the
// counter sees writes; the four reads add none.
func TestReportsWriteNothing(t *testing.T) {
e := newEnv(t)
count := func() int {
var n int
// No river_ table here: internal/modules may not name one, tests
// included (rls_exempt.txt). A GET that enqueued would also have to
// write report_export, which this counts.
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(),
`SELECT (SELECT count(*) FROM audit_log WHERE org_id = $1) + (SELECT count(*) FROM report_export WHERE org_id = $1)`,
e.acc.Org).Scan(&n))
return n
}
before := count()
require.Positive(t, before)
for _, path := range []string{"/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31",
"/v1/reports/balance-sheet?as_of=2026-12-31", "/v1/reports/general-ledger?from=2026-01-01&to=2026-12-31"} {
require.Equal(t, http.StatusOK, e.get(t, path, e.readerTok).Code)
}
require.Equal(t, before, count())
}

routes_test.go — the pattern of internal/modules/accounting/routes_test.go: httpx.ResetAuthorizedRoutes(), register with reports.Deps{}, and require httpx.AuthorizedRoutes() to equal exactly:

want := map[string]string{
"GET /v1/reports/trial-balance": reports.PermissionRead,
"GET /v1/reports/profit-and-loss": reports.PermissionRead,
"GET /v1/reports/balance-sheet": reports.PermissionRead,
"GET /v1/reports/general-ledger": reports.PermissionRead,
}

plus the two key literals ("reports.read", "reports.export") and gin’s own route table under /v1/reports/ having exactly len(want) entries.

  • Step 3: Run and watch fail. go test ./internal/modules/reports/ -count=1 → FAIL (package missing).

  • Step 4: Implement the module.

    • routes.go: package comment in the style of modules/accounting (thin shell; errors recorded with c.Error; every read in InReadOnlyTx, Ruling R4). Deps as above. Register: the four routes above through httpx.Protected(g, p, …) with a permissionLoader copied from modules/accounting/routes.go (its comment explains why it is copied, not shared).
    • http.go:
func allowQuery(c *gin.Context, report string, allowed ...string) error {
ok := map[string]bool{}
for _, a := range allowed {
ok[a] = true
}
keys := make([]string, 0, len(c.Request.URL.Query()))
for k := range c.Request.URL.Query() {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !ok[k] {
return apperr.New(apperr.KindValidation, fmt.Sprintf("the %s does not take %s", report, k))
}
}
return nil
}
func dateParam(c *gin.Context, name string, required bool) (*time.Time, error) {
v := c.Query(name)
if v == "" {
if required {
return nil, apperr.New(apperr.KindValidation, name+" is required, as YYYY-MM-DD")
}
return nil, nil
}
d, err := time.Parse("2006-01-02", v)
if err != nil {
return nil, apperr.New(apperr.KindValidation, name+" must be a date YYYY-MM-DD")
}
return &d, nil
}
func uuidParam(c *gin.Context, name string) (*uuid.UUID, error) {
v := c.Query(name)
if v == "" {
return nil, nil
}
id, err := uuid.Parse(v)
if err != nil {
return nil, apperr.New(apperr.KindValidation, name+" must be a UUID")
}
return &id, nil
}
func uuidsParam(c *gin.Context, name string) ([]uuid.UUID, error) {
var out []uuid.UUID
for _, v := range c.QueryArray(name) {
id, err := uuid.Parse(v)
if err != nil {
return nil, apperr.New(apperr.KindValidation, name+" must be UUIDs")
}
out = append(out, id)
}
return out, nil
}
func boolParam(c *gin.Context, name string) (bool, error) {
switch c.Query(name) {
case "", "false":
return false, nil
case "true":
return true, nil
}
return false, apperr.New(apperr.KindValidation, name+" must be true or false")
}
func accountLabels(ctx context.Context, tx pgx.Tx, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return i18n.Load(ctx, tx, "account", ids, "name")
}
type accountDTO struct {
AccountID uuid.UUID `json:"account_id"`
Code string `json:"code"`
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"`
Depth int `json:"depth"`
}
// accountDTO is embedded in every report row: encoding/json promotes an
// embedded struct type's exported fields into the outer object, so a row
// marshals as {account_id, code, name, label, type, depth, …}.
func toAccount(a reporting.AccountRef, labels map[uuid.UUID]string) accountDTO {
l, ok := labels[a.ID]
if !ok {
l = a.Name
}
return accountDTO{AccountID: a.ID, Code: a.Code, Name: a.Name, Label: l, Type: a.Type, Depth: a.Depth}
}
  • errors.go: mapErr(err error) error — reporting.ErrInvalidRange → 422 “from must be a date on or before to”; reporting.ErrAccountNotFound → 422 “account_id names no account of this organization”; reporting.ErrAnalyticAccountNotFound → 422; reporting.ErrAnalyticAxesMixed → 422 “analytic_account_id values must all belong to one analytic axis (one axis per report)”; reporting.ErrBadCursor → 422; a *apperr.Problem passes through; anything else → apperr.New(apperr.KindInternal, "the report could not be produced") (the cause is logged by httpx.Errors, as in modules/accounting).
  • report_handlers.go — DTOs (snake_case JSON; money.Amount fields marshal as strings; every slice initialised with make(…, 0)):
type tbRowDTO struct {
accountDTO
OpeningDebit money.Amount `json:"opening_debit"`
OpeningCredit money.Amount `json:"opening_credit"`
Debit money.Amount `json:"debit"`
Credit money.Amount `json:"credit"`
ClosingDebit money.Amount `json:"closing_debit"`
ClosingCredit money.Amount `json:"closing_credit"`
}
type tbTotalsDTO struct {
OpeningDebit money.Amount `json:"opening_debit"`
OpeningCredit money.Amount `json:"opening_credit"`
Debit money.Amount `json:"debit"`
Credit money.Amount `json:"credit"`
ClosingDebit money.Amount `json:"closing_debit"`
ClosingCredit money.Amount `json:"closing_credit"`
}
type trialBalanceDTO struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
Provisional bool `json:"provisional"`
Filtered bool `json:"filtered"`
ExcludeClosing bool `json:"exclude_closing"`
Rows []tbRowDTO `json:"rows"`
Totals tbTotalsDTO `json:"totals"`
Balanced bool `json:"balanced"`
}
type statementRowDTO struct {
accountDTO
Header bool `json:"header"`
Amount money.Amount `json:"amount"`
}
type profitAndLossDTO struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
Provisional bool `json:"provisional"`
Filtered bool `json:"filtered"`
Income []statementRowDTO `json:"income"`
Expenses []statementRowDTO `json:"expenses"`
TotalIncome money.Amount `json:"total_income"`
TotalExpenses money.Amount `json:"total_expenses"`
NetProfit money.Amount `json:"net_profit"`
}
type balanceSheetDTO struct {
AsOf string `json:"as_of"`
Currency string `json:"currency"`
Provisional bool `json:"provisional"`
Assets []statementRowDTO `json:"assets"`
Liabilities []statementRowDTO `json:"liabilities"`
Equity []statementRowDTO `json:"equity"`
CurrentYearEarnings money.Amount `json:"current_year_earnings"`
PriorUnclosedEarnings money.Amount `json:"prior_unclosed_earnings"`
TotalAssets money.Amount `json:"total_assets"`
TotalLiabilities money.Amount `json:"total_liabilities"`
TotalEquity money.Amount `json:"total_equity"`
Balanced bool `json:"balanced"`
}
type glLineDTO struct {
EntryID uuid.UUID `json:"entry_id"`
EntryNo string `json:"entry_no"`
EntryDate string `json:"entry_date"`
JournalCode string `json:"journal_code"`
Semantic string `json:"semantic"`
Description string `json:"description"`
Memo string `json:"memo"`
LineNo int `json:"line_no"`
Debit money.Amount `json:"debit"`
Credit money.Amount `json:"credit"`
Balance money.Amount `json:"balance"`
Currency string `json:"currency"`
TxDebit *money.Amount `json:"tx_debit"`
TxCredit *money.Amount `json:"tx_credit"`
SourceKind *string `json:"source_kind"`
SourceID *uuid.UUID `json:"source_id"`
ReversalOfID *uuid.UUID `json:"reversal_of_id"`
}
type glSectionDTO struct {
accountDTO
Opening money.Amount `json:"opening"`
Closing money.Amount `json:"closing"`
Lines []glLineDTO `json:"lines"`
}
type generalLedgerDTO struct {
From string `json:"from"`
To string `json:"to"`
Currency string `json:"currency"`
Provisional bool `json:"provisional"`
Filtered bool `json:"filtered"`
Accounts []glSectionDTO `json:"accounts"`
NextCursor *string `json:"next_cursor"`
}

Handler shape (trial balance shown; the other three follow it exactly with their own params and DTO mapping):

func (h handlers) getTrialBalance(c *gin.Context) {
if err := allowQuery(c, "trial balance", "from", "to", "account_id", "exclude_closing"); err != nil {
_ = c.Error(err)
return
}
from, err := dateParam(c, "from", true)
if err != nil {
_ = c.Error(err)
return
}
to, err := dateParam(c, "to", true)
if err != nil {
_ = c.Error(err)
return
}
acct, err := uuidParam(c, "account_id")
if err != nil {
_ = c.Error(err)
return
}
excl, err := boolParam(c, "exclude_closing")
if err != nil {
_ = c.Error(err)
return
}
var out trialBalanceDTO
err = h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
r, err := reporting.TrialBalance(ctx, tx, reporting.TrialBalanceParams{
Range: reporting.Range{From: *from, To: *to}, AccountID: acct, ExcludeClosing: excl})
if err != nil {
return err
}
ids := make([]uuid.UUID, len(r.Rows))
for i, row := range r.Rows {
ids[i] = row.Account.ID
}
labels, err := accountLabels(ctx, tx, ids)
if err != nil {
return err
}
out = trialBalanceDTO{From: from.Format("2006-01-02"), To: to.Format("2006-01-02"), Currency: r.Currency,
Provisional: r.Provisional, Filtered: r.Filtered, ExcludeClosing: excl, Balanced: r.Balanced,
Rows: make([]tbRowDTO, 0, len(r.Rows)),
Totals: tbTotalsDTO{r.Totals.OpeningDebit, r.Totals.OpeningCredit, r.Totals.Debit, r.Totals.Credit,
r.Totals.ClosingDebit, r.Totals.ClosingCredit}}
for _, row := range r.Rows {
out.Rows = append(out.Rows, tbRowDTO{toAccount(row.Account, labels), row.OpeningDebit, row.OpeningCredit,
row.Debit, row.Credit, row.ClosingDebit, row.ClosingCredit})
}
return nil
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, out)
}

The general-ledger handler parses cursor with reporting.DecodeGLCursor (error → mapErr), limit with strconv.Atoi (default reporting.DefaultGLPage; outside 1..MaxGLPage → 422), and uses a visitor that appends a glSectionDTO on Account (with Opening and Closing = opening) and a glLineDTO on Line (setting the section’s Closing to the line’s balance); labels are loaded after streaming, for the sections’ account ids. NextCursor is res.Next.Encode() when res.Next != nil.

  • Step 5: OpenAPI. Append to the end of paths: in api/openapi.yaml (a header comment first, in the file’s style: “reports: read-only statements from the posted ledger (Plan 9). Base currency; amounts are Money strings. Every read requires reports.read.”):
/reports/trial-balance:
get:
operationId: getTrialBalance
summary: Trial balance over a date range
description: >
Opening, movement and closing base-currency balances per postable
account, from posted entries only. Includes the year-end closing
entry unless exclude_closing is true (pre-closing trial balance).
Requires reports.read. Refuses any query parameter not listed (422).
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
- { name: account_id, in: query, schema: { type: string, format: uuid }, description: That account and its subtree. }
- { name: exclude_closing, in: query, schema: { type: boolean } }
responses:
'200': { description: The trial balance, content: { application/json: { schema: { $ref: '#/components/schemas/TrialBalance' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { description: The caller does not hold reports.read., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'422': { description: A missing, malformed or unaccepted parameter, or an unknown account., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
/reports/profit-and-loss:
get:
operationId: getProfitAndLoss
summary: Profit and loss over a date range
description: >
Income and expense by account tree, excluding year-end closing
entries so a closed year still shows its result. An analytic filter
(one axis; descendants included) reports the stored distribution
shares. Requires reports.read.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
- { name: account_id, in: query, schema: { type: string, format: uuid } }
- { name: analytic_account_id, in: query, style: form, explode: true, schema: { type: array, items: { type: string, format: uuid } } }
responses:
'200': { description: The profit and loss, content: { application/json: { schema: { $ref: '#/components/schemas/ProfitAndLoss' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { description: The caller does not hold reports.read., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'422': { description: A missing, malformed or unaccepted parameter, or analytic accounts on two axes., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
/reports/balance-sheet:
get:
operationId: getBalanceSheet
summary: Balance sheet as of a date
description: >
Assets, liabilities and equity from every posted entry through
as_of, with earnings not yet closed to retained earnings shown as
current_year_earnings and prior_unclosed_earnings. Requires reports.read.
parameters:
- { name: as_of, in: query, required: true, schema: { type: string, format: date } }
responses:
'200': { description: The balance sheet, content: { application/json: { schema: { $ref: '#/components/schemas/BalanceSheet' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { description: The caller does not hold reports.read., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'422': { description: A missing, malformed or unaccepted parameter., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
/reports/general-ledger:
get:
operationId: getGeneralLedger
summary: General-ledger detail, paged
description: >
Every posted line in the range, by account code then posting order,
with a running balance; each account section carries its opening and
closing balance for the page. Page with next_cursor. Requires reports.read.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
- { name: account_id, in: query, schema: { type: string, format: uuid } }
- { name: analytic_account_id, in: query, style: form, explode: true, schema: { type: array, items: { type: string, format: uuid } } }
- { name: cursor, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 2000, default: 500 } }
responses:
'200': { description: One page of the general ledger, content: { application/json: { schema: { $ref: '#/components/schemas/GeneralLedger' } } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { description: The caller does not hold reports.read., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }
'422': { description: A missing, malformed or unaccepted parameter, or a bad cursor., content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } }

Append to the end of components.schemas (every amount $ref: '#/components/schemas/Money'; every property listed is required):

  • ReportAccount: account_id (uuid), code, name, label, type (enum asset|liability|equity|income|expense), depth (integer).
  • TrialBalanceRow: the ReportAccount properties plus opening_debit, opening_credit, debit, credit, closing_debit, closing_credit (Money).
  • TrialBalance: from, to (date), currency, provisional, filtered, exclude_closing (boolean), rows (array of TrialBalanceRow), totals (object: the six Money fields), balanced (boolean).
  • StatementRow: the ReportAccount properties plus header (boolean), amount (Money).
  • ProfitAndLoss: from, to, currency, provisional, filtered, income, expenses (arrays of StatementRow), total_income, total_expenses, net_profit (Money).
  • BalanceSheet: as_of, currency, provisional, assets, liabilities, equity (arrays of StatementRow), current_year_earnings, prior_unclosed_earnings, total_assets, total_liabilities, total_equity (Money), balanced.
  • GeneralLedgerLine: entry_id (uuid), entry_no, entry_date (date), journal_code, semantic (enum accrual|payout|reversal|manual|year_end_close), description, memo, line_no (integer), debit, credit, balance (Money), currency, tx_debit, tx_credit (Money, nullable), source_kind (string, nullable), source_id, reversal_of_id (uuid, nullable).
  • GeneralLedgerSection: the ReportAccount properties plus opening, closing (Money), lines (array of GeneralLedgerLine).
  • GeneralLedger: from, to, currency, provisional, filtered, accounts (array of GeneralLedgerSection), next_cursor (string, nullable).

Write them flat (no allOf), matching the existing schemas’ style; make generate.

  • Step 6: Compose.
    • internal/app/app.go: import internal/modules/reports; append Reports reports.Deps to Deps; append reports.Register(api, deps.Reports) after accounting.Register.
    • internal/app/routes_test.go: append "reports": {reports.PermissionRead, reports.PermissionExport}, to composedModules.
    • cmd/api/main.go: before app.Router, build the object store and the River inserter (if Plan 5 already builds store in this file, reuse that variable and skip the first block):
objCfg, err := config.ObjectStoreFromEnv()
if err != nil {
log.Fatalf("config: %v", err)
}
store, err := s3store.New(objCfg)
if err != nil {
log.Fatalf("object store: %v", err)
}
if err := store.EnsureBucket(context.Background()); err != nil {
log.Fatalf("object store: %v", err)
}
// Insert-only: the API enqueues inside its request transactions; the
// worker runs the jobs (Ruling R16).
inserter, err := jobs.NewInserter(database.Pool())
if err != nil {
log.Fatalf("jobs: %v", err)
}
and in `app.Deps`: `Reports: reports.Deps{DB: database, Clock: clock.System(), Jobs: inserter, Store: store, PresignTTL: objCfg.PresignTTL},`.
  • cmd/migrate/main.go and cmd/orgadmin/main.go: append the blank import _ "github.com/mcom/erp/internal/modules/reports"; in their wiring_test.go append the package path and "reports.read", "reports.export", // modules/reports to the expected lists.

  • Step 7: Run. go test ./internal/modules/reports/ ./internal/app/ ./cmd/... ./internal/kernel/layering/ ./internal/kernel/config/ -count=1 → PASS (TestEveryRouteIsAuthorized, the OpenAPI coverage test, TestEveryModuleContributesRoutesAndPermissions, TestEveryBinaryLinksTheSameModules, TestModulesNeverReferenceRLSExemptTables — the module, tests included, names no river_ table).

  • Step 8: Sabotage once. Register general-ledger under PermissionExport → routes_test.go red; drop allowQuery from the balance-sheet handler → TestFiltersAReportDoesNotTakeAreRefused red. Restore; quote both.

  • Step 9: Commit

Terminal window
git add internal/modules/reports constants.yaml internal/kernel/vocab packages/contracts api/openapi.yaml internal/gen/api \
internal/app cmd/api cmd/migrate cmd/orgadmin
git commit -m "Serve the trial balance, P&L, balance sheet and GL detail under /v1/reports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 15: Export routes — request, list, read, download

Section titled “Task 15: Export routes — request, list, read, download”

Files:

  • Create: internal/modules/reports/export_handlers.go
  • Test: internal/modules/reports/export_handlers_test.go
  • Modify: internal/modules/reports/routes.go, internal/modules/reports/routes_test.go, internal/modules/reports/errors.go, api/openapi.yaml (+ internal/gen/api)

Interfaces:

  • Consumes: export.Create, Get, List, Run, RunDeps, Export, Request, Params, the errors (Tasks 12–13); Task 14’s Deps, harness and helpers.
  • Produces: the routes below.
Method Path (under /v1) Permission operationId Body 2xx
POST /reports/exports reports.export requestReportExport ReportExportRequest 202 ReportExport + Location
GET /reports/exports reports.read listReportExports limit (1–100, default 50), offset 200 ReportExportList
GET /reports/exports/{id} reports.read getReportExport — 200 ReportExport
GET /reports/exports/{id}/download reports.read downloadReportExport — 200 ReportExportDownload; 409 until succeeded
  • Step 1: Write the failing tests — export_handlers_test.go:
type exportResp struct {
ID uuid.UUID `json:"id"`
Report string `json:"report"`
Format string `json:"format"`
Params json.RawMessage `json:"params"`
Status string `json:"status"`
RequestedBy uuid.UUID `json:"requested_by"`
Filename string `json:"filename"`
SHA256 *string `json:"sha256"`
ByteSize *int64 `json:"byte_size"`
FailureCode *string `json:"failure_code"`
}
var tbBody = map[string]any{"report": "trial_balance", "format": "csv",
"params": map[string]any{"from": "2026-01-01", "to": "2026-12-31"}}
func (e *env) requestExport(t *testing.T, body any) exportResp {
t.Helper()
w := e.do(t, http.MethodPost, "/v1/reports/exports", e.exporterTok, body)
require.Equal(t, http.StatusAccepted, w.Code, w.Body.String())
x := decode[exportResp](t, w)
require.Equal(t, "/v1/reports/exports/"+x.ID.String(), w.Header().Get("Location"))
return x
}
// work runs the queued job in-process, as the worker would.
func (e *env) work(t *testing.T, id uuid.UUID) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
require.NoError(t, export.Run(ctx, export.RunDeps{DB: e.acc.DB, Store: e.store, Clock: e.acc.Clock},
export.JobArgs{OrgID: e.acc.Org, ExportID: id}, 1, 3))
}
func TestAnExportFromRequestToDownload(t *testing.T) {
e := newEnv(t)
x := e.requestExport(t, tbBody)
require.Equal(t, "queued", x.Status)
require.Equal(t, e.exporter, x.RequestedBy)
require.Equal(t, "trial_balance_2026-01-01_2026-12-31.csv", x.Filename)
w := e.get(t, "/v1/reports/exports/"+x.ID.String()+"/download", e.readerTok)
require.Equal(t, http.StatusConflict, w.Code, "not ready yet")
e.work(t, x.ID)
got := decode[exportResp](t, e.get(t, "/v1/reports/exports/"+x.ID.String(), e.readerTok))
require.Equal(t, "succeeded", got.Status)
require.Len(t, *got.SHA256, 64, "hex SHA-256")
w = e.get(t, "/v1/reports/exports/"+x.ID.String()+"/download", e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
dl := decode[struct {
URL string `json:"url"`
Filename string `json:"filename"`
ExpiresAt string `json:"expires_at"`
}](t, w)
require.Contains(t, dl.URL, "reports/"+e.acc.Org.String()+"/"+x.ID.String()+"/")
require.Equal(t, x.Filename, dl.Filename)
list := decode[struct {
Exports []exportResp `json:"exports"`
}](t, e.get(t, "/v1/reports/exports", e.readerTok))
require.Equal(t, x.ID, list.Exports[0].ID)
}
func TestOnlyExportersRequestExports(t *testing.T) {
e := newEnv(t)
e.requestExport(t, tbBody) // PRESENCE
require.Equal(t, http.StatusForbidden, e.do(t, http.MethodPost, "/v1/reports/exports", e.readerTok, tbBody).Code)
}
func TestExportRequestsAreValidated(t *testing.T) {
e := newEnv(t)
e.requestExport(t, tbBody) // PRESENCE
for name, body := range map[string]any{
"unknown report": map[string]any{"report": "cash_flow", "format": "csv", "params": map[string]any{}},
"unknown format": map[string]any{"report": "trial_balance", "format": "docx", "params": tbBody["params"]},
"wrong param": map[string]any{"report": "balance_sheet", "format": "pdf", "params": map[string]any{"as_of": "2026-12-31", "from": "2026-01-01"}},
"backwards": map[string]any{"report": "trial_balance", "format": "csv", "params": map[string]any{"from": "2026-12-31", "to": "2026-01-01"}},
"no account": map[string]any{"report": "general_ledger", "format": "csv", "params": map[string]any{"from": "2026-01-01", "to": "2026-12-31", "account_id": uuid.NewString()}},
} {
w := e.do(t, http.MethodPost, "/v1/reports/exports", e.exporterTok, body)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, name+": "+w.Body.String())
}
w := e.do(t, http.MethodPost, "/v1/reports/exports", e.exporterTok, `{"report":"trial_balance","format":"csv","params":{},"extra":1}`)
require.Equal(t, http.StatusBadRequest, w.Code, "unknown body fields are refused")
}
func TestATooLargeGeneralLedgerExportIs422(t *testing.T) {
e := newEnv(t)
old := export.MaxLines[render.FormatPDF]
t.Cleanup(func() { export.MaxLines[render.FormatPDF] = old })
export.MaxLines[render.FormatPDF] = 2
w := e.do(t, http.MethodPost, "/v1/reports/exports", e.exporterTok, map[string]any{"report": "general_ledger", "format": "pdf",
"params": map[string]any{"from": "2026-01-01", "to": "2026-12-31"}})
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
require.Contains(t, w.Body.String(), "at most 2")
}
// TestAnotherOrgsExportIsNotFound: PRESENCE — our reader reads it.
func TestAnotherOrgsExportIsNotFound(t *testing.T) {
e := newEnv(t)
x := e.requestExport(t, tbBody)
require.Equal(t, http.StatusOK, e.get(t, "/v1/reports/exports/"+x.ID.String(), e.readerTok).Code)
other := e.acc.Another(t, "Other Org")
m := other.SeedMember(t, "Other Reader")
e.grant(t, other.Org, m, reports.PermissionRead)
tok := e.mint(t, other.Org, m)
require.Equal(t, http.StatusNotFound, e.get(t, "/v1/reports/exports/"+x.ID.String(), tok).Code)
require.Equal(t, http.StatusNotFound, e.get(t, "/v1/reports/exports/"+x.ID.String()+"/download", tok).Code)
}

Extend routes_test.go’s want with the four rows of the table above.

  • Step 2: Run and watch fail. go test ./internal/modules/reports/ -count=1 → FAIL.

  • Step 3: Implement export_handlers.go. Copy decodeStrict and its helpers (decodeProblem, wrongTypeField, failingField, probeFields) from internal/modules/accounting/http.go into http.go (the house pattern: copied, not shared). Then:

type exportRequestDTO struct {
Report string `json:"report"`
Format string `json:"format"`
Params export.Params `json:"params"`
}
type exportDTO struct {
ID uuid.UUID `json:"id"`
Report string `json:"report"`
Format string `json:"format"`
Params export.Params `json:"params"`
Status string `json:"status"`
RequestedBy uuid.UUID `json:"requested_by"`
RequestedAt time.Time `json:"requested_at"`
FinishedAt *time.Time `json:"finished_at"`
Filename string `json:"filename"`
ContentType *string `json:"content_type"`
ByteSize *int64 `json:"byte_size"`
SHA256 *string `json:"sha256"` // hex
RowCount *int64 `json:"row_count"`
FailureCode *string `json:"failure_code"`
}
func toExportDTO(x export.Export) exportDTO {
d := exportDTO{ID: x.ID, Report: x.Report, Format: string(x.Format), Params: x.Params, Status: x.Status,
RequestedBy: x.RequestedBy, RequestedAt: x.RequestedAt.UTC(), Filename: x.Filename(),
ContentType: x.ContentType, ByteSize: x.ByteSize, RowCount: x.RowCount, FailureCode: x.FailureCode}
if x.FinishedAt != nil {
f := x.FinishedAt.UTC()
d.FinishedAt = &f
}
if x.SHA256 != nil {
h := hex.EncodeToString(x.SHA256)
d.SHA256 = &h
}
return d
}
func (h handlers) requestReportExport(c *gin.Context) {
var req exportRequestDTO
if err := decodeStrict(c, &req); err != nil {
_ = c.Error(err)
return
}
by, ok := audit.ActorFromContext(c.Request.Context())
if !ok {
_ = c.Error(apperr.New(apperr.KindUnauthorized, "no authenticated member"))
return
}
var x export.Export
err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = export.Create(ctx, tx, h.deps.Clock, h.deps.Jobs,
export.Request{Report: req.Report, Format: render.Format(req.Format), Params: req.Params}, by.MemberID)
return err
})
if err != nil {
_ = c.Error(mapErr(err))
return
}
c.Header("Location", "/v1/reports/exports/"+x.ID.String())
c.JSON(http.StatusAccepted, toExportDTO(x))
}
func (h handlers) downloadReportExport(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
_ = c.Error(apperr.New(apperr.KindValidation, "the id in the path must be a UUID"))
return
}
var x export.Export
if err := h.deps.DB.InReadOnlyTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = export.Get(ctx, tx, id)
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
if x.Status != export.StatusSucceeded {
_ = c.Error(apperr.New(apperr.KindConflict, "the export is "+x.Status+"; only a succeeded export has a file"))
return
}
// Outside the transaction: object-store I/O never holds one open.
url, err := h.deps.Store.PresignGet(c.Request.Context(), *x.ObjectKey, x.Filename(), h.deps.PresignTTL)
if err != nil {
_ = c.Error(apperr.New(apperr.KindInternal, "the download link could not be issued"))
return
}
c.JSON(http.StatusOK, gin.H{"url": url, "filename": x.Filename(),
"expires_at": h.deps.Clock.Now().UTC().Add(h.deps.PresignTTL).Format(time.RFC3339)})
}

listReportExports (limit/offset via strconv.Atoi, malformed → 422; export.List in InReadOnlyTx; body {"exports": [...]} never null) and getReportExport (export.Get in InReadOnlyTx) follow the same shape. Register the four routes in routes.go.

errors.go gains: export.ErrNotFound → 404; export.ErrUnknownReport, export.ErrUnknownFormat → 422 (“report must be one of trial_balance, profit_and_loss, balance_sheet, general_ledger” / “format must be one of csv, xlsx, pdf”); *export.InvalidParamsError → 422 with its Error(); *export.TooLargeError → 422 with its Error() plus “ — narrow the date range or the account” (Ruling R12).

  • Step 4: OpenAPI. Append the four operations (paths /reports/exports, /reports/exports/{id}, /reports/exports/{id}/download) with the permissions, bodies and statuses of the table above (202 with a Location header; 409 on download with a Problem; 404 on an id of no export of the caller’s organization), and schemas:

    • ReportExportParams: optional from, to, as_of (date), account_id (uuid), analytic_account_ids (array of uuid), exclude_closing (boolean); additionalProperties: false.
    • ReportExportRequest: required report (enum trial_balance|profit_and_loss|balance_sheet|general_ledger), format (enum csv|xlsx|pdf), params (ReportExportParams).
    • ReportExport: required id, report, format, params, status (enum queued|succeeded|failed), requested_by, requested_at (date-time), filename; nullable finished_at, content_type, byte_size (integer), sha256 (hex string), row_count (integer), failure_code (enum too_large|invalid_parameters|internal).
    • ReportExportList: exports (array of ReportExport).
    • ReportExportDownload: url, filename, expires_at (date-time).

    make generate.

  • Step 5: Run. go test ./internal/modules/reports/ ./internal/app/ -count=1 → PASS.

  • Step 6: Commit

Terminal window
git add internal/modules/reports api/openapi.yaml internal/gen/api
git commit -m "Request, list, read and download report exports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Files:

  • Create: cmd/worker/exports.go, cmd/worker/exports_test.go
  • Modify: cmd/worker/main.go, docs/DEPLOY_NOTES.md

Interfaces:

  • Consumes: jobs.NewWorkerClient, jobs.WorkerConfig, jobs.QueueReports, jobs.NewInserter (Task 3); export.Worker, export.RunDeps, export.Create, export.Get (Tasks 12–13); config.ObjectStoreFromEnv, s3store.New (Task 4).

  • Produces:

    package main
    // startExportWorker starts River's worker client with the report-export
    // worker registered (Ruling R16). The caller stops it.
    func startExportWorker(ctx context.Context, database *db.DB, store export.Store, cfg jobs.WorkerConfig) (*river.Client[pgx.Tx], error)
  • Step 1: Write the failing test — exports_test.go (package main; bounded; cleans up):

// TestTheWorkerRunsAnExport end to end through River: a request enqueued in
// its own transaction is picked up by the worker client and finished.
func TestTheWorkerRunsAnExport(t *testing.T) {
e := accountingtest.New(t)
g := reportingtest.Seed(t, e, reportingtest.Options{CloseFY2026: true})
store := documentstest.NewMemStore()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := startExportWorker(ctx, e.DB, store, jobs.WorkerConfig{
Queues: map[string]int{jobs.QueueReports: 1}, FetchPollInterval: 50 * time.Millisecond})
require.NoError(t, err)
t.Cleanup(func() {
stop, c := context.WithTimeout(context.Background(), 10*time.Second)
defer c()
_ = client.Stop(stop)
})
ins, err := jobs.NewInserter(e.DB.Pool())
require.NoError(t, err)
var x export.Export
require.NoError(t, e.DB.InTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
x, err = export.Create(ctx, tx, e.Clock, ins, export.Request{Report: export.ReportBalanceSheet,
Format: render.FormatPDF, Params: export.Params{AsOf: "2026-12-31"}}, g.Member)
return err
}))
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
var got export.Export
require.NoError(t, e.DB.InReadOnlyTx(e.Ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = export.Get(ctx, tx, x.ID)
return err
}))
if got.Status == export.StatusSucceeded {
body, ok := store.Object(*got.ObjectKey)
require.True(t, ok)
require.True(t, bytes.HasPrefix(body, []byte("%PDF-")))
return
}
require.Equal(t, export.StatusQueued, got.Status)
select {
case <-ctx.Done():
t.Fatal("the worker never finished the export")
case <-tick.C:
}
}
}
  • Step 2: Run and watch fail. go test ./cmd/worker/ -run Export -count=1 → FAIL (undefined).

  • Step 3: Implement exports.go.

package main
// startExportWorker is the worker's River client (Rulings R16–R18): poll-only,
// one queue, one job kind so far. Adding a job kind is one more AddWorker here.
func startExportWorker(ctx context.Context, database *db.DB, store export.Store, cfg jobs.WorkerConfig) (*river.Client[pgx.Tx], error) {
workers := river.NewWorkers()
river.AddWorker(workers, &export.Worker{Deps: export.RunDeps{
DB: database, Store: store, Clock: clock.System(), Logger: cfg.Logger}})
client, err := jobs.NewWorkerClient(database.Pool(), workers, cfg)
if err != nil {
return nil, err
}
if err := client.Start(ctx); err != nil {
return nil, err
}
return client, nil
}

main.go: after the BNM block and before runPruneLoop:

objCfg, err := config.ObjectStoreFromEnv()
if err != nil {
log.Fatalf("config: %v", err)
}
store, err := s3store.New(objCfg)
if err != nil {
log.Fatalf("object store: %v", err)
}
if err := store.EnsureBucket(ctx); err != nil {
log.Fatalf("object store: %v", err)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
exports, err := startExportWorker(ctx, database, store, jobs.WorkerConfig{
Queues: map[string]int{jobs.QueueReports: 2}, Logger: logger})
if err != nil {
log.Fatalf("jobs: %v", err)
}
log.Println("worker started; running report exports from River queue " + jobs.QueueReports)
defer func() {
stop, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := exports.Stop(stop); err != nil {
log.Printf("jobs: stop: %v", err)
}
}()

Update the file’s leading comment: River now runs report exports; session.Prune is still the only scheduled deletion the application performs itself (River’s own cleaner deletes finished job rows, which retention_test.go lists as operational).

  • Step 4: docs/DEPLOY_NOTES.md — a new top section “Plan 9 — reports, exports and River”:

    1. River’s schema ships as goose migrations 00110–00116; run migrate up (the privileged migrate job) before starting api or worker. Never run River’s own river migrate-up in production — goose owns the schema.
    2. The worker now needs the four OBJECT_STORE_* settings, like the api, and refuses to boot without them.
    3. The worker polls (PollOnly), so it works behind pgbouncer in transaction mode; expect up to a second before an export starts.
    4. Export files live under reports/<org>/<export>/ in the documents bucket; the bucket’s versioning and 7-year object lock (Plan 5/9 object-store section) cover them. Nothing deletes an export.
    5. River deletes finished job rows after 24 h; the record of an export is report_export and audit_log, which are kept.
  • Step 5: Run. go test ./cmd/worker/ -count=1 → PASS (the new test and TestWorkerHasTheLedgerPoster). go build ./cmd/....

  • Step 6: Commit

Terminal window
git add cmd/worker docs/DEPLOY_NOTES.md
git commit -m "Run report exports on River in the worker
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 17: End to end, the roadmap, and the one full run

Section titled “Task 17: End to end, the roadmap, and the one full run”

Files:

  • Create: internal/modules/reports/e2e_test.go

  • Modify: docs/ROADMAP.md

  • Step 1: Write the end-to-end test — e2e_test.go, on Task 14’s harness plus an in-process River worker client:

// startWorker runs River's worker client in-process with the export worker,
// exactly as cmd/worker does, stopped in t.Cleanup.
func (e *env) startWorker(t *testing.T) {
t.Helper()
workers := river.NewWorkers()
river.AddWorker(workers, &export.Worker{Deps: export.RunDeps{DB: e.acc.DB, Store: e.store, Clock: e.acc.Clock}})
client, err := jobs.NewWorkerClient(e.acc.DB.Pool(), workers, jobs.WorkerConfig{
Queues: map[string]int{jobs.QueueReports: 1}, FetchPollInterval: 50 * time.Millisecond})
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
require.NoError(t, client.Start(ctx))
t.Cleanup(func() {
stop, c := context.WithTimeout(context.Background(), 10*time.Second)
defer c()
_ = client.Stop(stop)
cancel()
})
}
// awaitExport polls GET /v1/reports/exports/{id} until it is terminal.
func (e *env) awaitExport(t *testing.T, id uuid.UUID) exportResp {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
x := decode[exportResp](t, e.get(t, "/v1/reports/exports/"+id.String(), e.readerTok))
if x.Status != "queued" {
require.Equal(t, "succeeded", x.Status, "failure_code %v", x.FailureCode)
return x
}
select {
case <-ctx.Done():
t.Fatal("the export never finished")
case <-tick.C:
}
}
}
// download follows the download link into the in-memory store.
func (e *env) download(t *testing.T, id uuid.UUID) []byte {
t.Helper()
w := e.get(t, "/v1/reports/exports/"+id.String()+"/download", e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
u := decode[struct {
URL string `json:"url"`
}](t, w).URL
key, _, _ := strings.Cut(strings.TrimPrefix(u, "mem://get/"), "?")
body, ok := e.store.Object(key)
require.True(t, ok, "no object at %q", key)
return body
}
// TestAnExportSaysWhatTheScreenSays is Ruling R34.
func TestAnExportSaysWhatTheScreenSays(t *testing.T) {
e := newEnv(t)
e.startWorker(t)
x := e.requestExport(t, tbBody)
e.awaitExport(t, x.ID)
body := e.download(t, x.ID)
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(body, []byte("\ufeff"))))
r.FieldsPerRecord = -1
records, err := r.ReadAll()
require.NoError(t, err)
byCode := map[string][]string{}
inTable := false
for _, rec := range records {
if !inTable {
inTable = len(rec) > 0 && rec[0] == "Code"
continue
}
byCode[rec[0]] = rec
}
require.NotEmpty(t, byCode, "the CSV had no table")
screen := decode[struct {
Rows []struct {
Code string `json:"code"`
OpeningDebit string `json:"opening_debit"`
OpeningCredit string `json:"opening_credit"`
Debit string `json:"debit"`
Credit string `json:"credit"`
ClosingDebit string `json:"closing_debit"`
ClosingCredit string `json:"closing_credit"`
} `json:"rows"`
Totals struct {
Debit string `json:"debit"`
Credit string `json:"credit"`
} `json:"totals"`
}](t, e.get(t, "/v1/reports/trial-balance?from=2026-01-01&to=2026-12-31", e.readerTok))
require.NotEmpty(t, screen.Rows)
for _, row := range screen.Rows {
rec, ok := byCode[row.Code]
require.True(t, ok, "account %s is on screen but not in the export", row.Code)
require.Equal(t, []string{row.OpeningDebit, row.OpeningCredit, row.Debit, row.Credit, row.ClosingDebit, row.ClosingCredit},
rec[2:8], row.Code)
}
require.Equal(t, screen.Totals.Debit, byCode["Total"][4])
require.Equal(t, screen.Totals.Credit, byCode["Total"][5])
require.Len(t, byCode, len(screen.Rows)+1, "the same accounts, plus the Total row")
var requestActor, completeActor *uuid.UUID
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(), `
SELECT (SELECT actor_id FROM audit_log WHERE org_id = $1 AND entity_id = $2 AND action = 'request'),
(SELECT actor_id FROM audit_log WHERE org_id = $1 AND entity_id = $2 AND action = 'complete')`,
e.acc.Org, x.ID).Scan(&requestActor, &completeActor))
require.NotNil(t, requestActor)
require.Equal(t, e.exporter, *requestActor, "who asked")
require.Nil(t, completeActor, "the system finished it")
}
// TestAGeneralLedgerExportHasEveryLineThePagesHave: the streamed export and
// the paged API agree on the line count.
func TestAGeneralLedgerExportHasEveryLineThePagesHave(t *testing.T) {
e := newEnv(t)
e.startWorker(t)
lines := 0
path := "/v1/reports/general-ledger?from=2026-01-01&to=2027-12-31&limit=2"
for next := ""; ; {
w := e.get(t, path+next, e.readerTok)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
p := decode[struct {
Accounts []struct {
Lines []json.RawMessage `json:"lines"`
} `json:"accounts"`
NextCursor *string `json:"next_cursor"`
}](t, w)
for _, a := range p.Accounts {
lines += len(a.Lines)
}
if p.NextCursor == nil {
break
}
next = "&cursor=" + url.QueryEscape(*p.NextCursor)
}
require.Positive(t, lines)
x := e.requestExport(t, map[string]any{"report": "general_ledger", "format": "xlsx",
"params": map[string]any{"from": "2026-01-01", "to": "2027-12-31"}})
got := e.awaitExport(t, x.ID)
var rowCount int64
require.NoError(t, e.acc.H.Admin.QueryRow(context.Background(),
`SELECT row_count FROM report_export WHERE org_id = $1 AND id = $2`, e.acc.Org, got.ID).Scan(&rowCount))
require.EqualValues(t, lines, rowCount)
_, err := excelize.OpenReader(bytes.NewReader(e.download(t, x.ID)))
require.NoError(t, err)
}
  • Step 2: Run. go test ./internal/modules/reports/ -run 'Screen|Pages' -count=1 → PASS.

  • Step 3: The roadmap. In docs/ROADMAP.md, replace Plan 9’s block with:

- [ ] **Plan 9 — Reports** (§10.7)
Plan: [`2026-09-21-plan-9-reports.md`](superpowers/plans/2026-09-21-plan-9-reports.md) (17 tasks)
- [x] Trial balance, P&L, balance sheet, GL detail — one query layer in `platform/reporting`,
ledger figures only, one read-only snapshot per report
- [ ] Aged advances (after Plan 7) · float register (after Plan 8) — specified in the plan's
*Deferred until Plans 7/8* section
- [x] Export to CSV / XLSX / PDF via River
- River lands here: schema vendored into goose (`00110`–`00116`), `kernel/jobs`, a poll-only
worker. Moving the BNM sync, nightly reconciliation and Plan 5's idempotency pruner onto it
is still open (Ruling R30)
  • Step 4: The one full run.
Terminal window
make generate && git diff --exit-code # generated files are current
make lint
make build
go test -p 2 -count=1 ./...

All green. Quote the summary lines in the task report.

  • Step 5: Commit
Terminal window
git add internal/modules/reports/e2e_test.go docs/ROADMAP.md
git commit -m "Prove exports match the screen; tick Plan 9's roadmap lines
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Not executable tasks (Ruling R31). Each stub names exactly what the upstream plan must expose, so that plan’s author builds it and this plan’s follow-up is a projection over the same query layer, with no new query engine (§10.7). Both follow Ruling R3: every amount comes from the ledger; the upstream plan contributes only identity, dates and ownership — never an amount a report prints.

What Plan 7 must expose (and must be true of its postings):

  1. Every advance’s ledger effect posts through Plan 4’s engine with source_kind = "advance" and source_id = <advance id>, on the account resolved from control kind employee_advances — issue (payout: Dr advances control / Cr bank), acquittal and refund/recovery (Cr advances control) alike. Then an advance’s outstanding amount as of D is the signed base sum of its lines on the control account dated ≤ D — ledger.SourceBalance’s arithmetic, with a date bound.
  2. A read-only function in internal/platform/advances:
    // AgingRef is what an aging report needs about an advance besides money.
    type AgingRef struct {
    ID uuid.UUID
    Reference string // e.g. "AD-2026-0007"
    HolderPartyID uuid.UUID
    HolderName string // record-language legal name
    IssuedOn time.Time // the payout's entry date
    DueOn time.Time // the acquittal due date (§7.6 reminders use the same date)
    }
    // ListForAging returns every advance issued on or before asOf, in the calling org.
    func ListForAging(ctx context.Context, tx pgx.Tx, asOf time.Time) ([]AgingRef, error)
  3. The §10.6 “Advances control” reconciliation check registered with reconcile.Register, so a report user can trust that the per-advance sums add up to the control account.

What Plan 9 then builds (one task): reporting.AgedAdvances(ctx, tx, AgedAdvancesParams{AsOf time.Time; HolderPartyID *uuid.UUID}) (AgedAdvancesReport, error) — one aggregate SELECT e.source_id, sum(l.base_debit - l.base_credit) … WHERE e.source_kind = 'advance' AND a.control_kind = 'employee_advances' AND e.entry_date <= $asOf GROUP BY e.source_id HAVING sum(...) <> 0, joined in Go to ListForAging’s refs; buckets by days past DueOn at AsOf: not yet due, 1–30, 31–60, 61–90, over 90; per-holder subtotals; a Balanced check that the bucket total equals the control account’s balance as of AsOf from movements. GET /v1/reports/aged-advances?as_of=&holder_party_id= (reports.read); export kind aged_advances — a migration in 00122+ that drops and re-adds report_export_report_known with the new value, and export.allowed gains {as_of, holder_party_id} (a new Params field). Tests on a golden advance fixture that Plan 7’s test package provides.

What Plan 8 must expose (and must be true of its postings):

  1. Each float is its own postable cash account (§8.1: “a float is a cash account someone is personally accountable for”), and every float movement — top-up, spend payout, replenishment, settlement, close-out — posts through Plan 4’s engine with source_kind naming the movement’s document kind and source_id its id, so the float’s register is the general ledger of that one account.
  2. A read-only function in internal/platform/pettycash:
    type FloatRef struct {
    ID uuid.UUID
    Code string
    HolderMemberID uuid.UUID
    HolderName string // record-language legal name
    AccountID uuid.UUID // the float's cash account
    OpenedOn time.Time
    ClosedOn *time.Time
    }
    func ListFloats(ctx context.Context, tx pgx.Tx) ([]FloatRef, error)
    func GetFloat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (FloatRef, error)
  3. The §10.6 “Float integrity” and “Float commitment” checks registered with reconcile.Register (the register prints their pass/fail, never float.cash_balance itself — that is a cached figure, not a ledger one).

What Plan 9 then builds (one task): reporting.FloatRegister(ctx, tx, FloatRegisterParams{FloatID uuid.UUID; Range Range}, v GLVisitor) (FloatRegisterReport, error) — GetFloat for the header, then GeneralLedger scoped to Scope{AccountID: &float.AccountID} (Task 9, unchanged), with the source kind mapped to a movement label (top-up, spend, replenishment, settlement) and the two reconciliation checks’ results in the header. GET /v1/reports/float-register?float_id=&from=&to=&cursor=&limit= (reports.read) and export kind float_register (same migration pattern as D1; streams through the GL projection with a float header). Tests on Plan 8’s golden float fixture.


Tasks in one wave touch disjoint files and may run in parallel; waves run in order. “Needs” lists the tasks whose output a task consumes. Shared-file conflicts inside a wave (go.mod/go.sum only) are resolved by re-running each side’s go get and go mod tidy; generated files by make generate.

Wave Tasks Needs Why they are disjoint
1 1 InReadOnlyTx · 2 River schema · 4 object-store seam · 10 CSV/XLSX — 1: kernel/db/readonly*.go. 2: kernel/jobs/schema*, tools/riverschema, 00110–00116, rls_exempt.txt, retention_test.go, sqlc models. 4: kernel/config/objectstore*, platform/documents/…, .env.example, DEPLOY_NOTES.md, this plan’s Reconciliation record. 10: reporting/render/{render,csv,xlsx}*. (2, 4, 10 each add modules to go.mod.)
2 3 River clients · 5 report_export · 6 query layer + golden books · 11 PDF 3: 2 · 5: 2 (retention list, sqlc models) · 6: 1 · 11: 10 3: kernel/jobs/jobs*.go. 5: 00120–00121, export/schema_test.go, retention_test.go, sqlc models. 6: reporting/{doc,scope,movements,tree,errors}.go + tests, reportingtest/. 11: render/pdf*, one line of render.go.
3 7 TB + P&L · 8 balance sheet · 9 GL detail 6 7: trialbalance.go, profitandloss.go, tree.go’s rollupFrom (7 alone edits tree.go). 8: balancesheet.go. 9: generalledger.go, cursor.go.
4 12 export requests 3, 5, 6, 9, 10 reporting/export/{export,params,errors}.go + tests.
5 13 export job · 14 module + composition + report routes 13: 4, 7, 8, 9, 11, 12 · 14: 1, 3, 4, 7, 8, 9, 12 13: export/{project,run,worker}.go + run_test.go. 14: modules/reports/…, constants.yaml, openapi.yaml, internal/app, cmd/{api,migrate,orgadmin}.
6 15 export routes · 16 worker 15: 13, 14 · 16: 3, 4, 13 15: modules/reports/export_*, routes.go, errors.go, openapi.yaml. 16: cmd/worker/…, DEPLOY_NOTES.md.
7 17 end to end + roadmap + full run all Alone.

Critical path: 1 → 6 → 9 → 12 → 13 → 15 → 17 (2 → 3 and 2 → 5 feed 12 in parallel).


  • Plan 5 (expense claims). (1) Its Task 2 (object-store half) and Task 4 may already be on main via this plan’s Task 4 — read the Reconciliation record below and verify-and-skip. (2) Its named-constraint guard (constraintnames_test.go, R3 there) must skip tables named river_%: River’s generated migrations carry River’s own names, some Postgres-generated (this plan’s R29). Whichever plan merges second adds the skip. (3) River is available: its idempotency-key pruner (R28 there) can be a River periodic job registered in cmd/worker/exports.go’s startExportWorker sibling — but River periodic jobs have no organization, so a pruner of a tenant table needs the org-enumeration decision below.
  • Plan 4’s R14 (nightly reconciliation). A River periodic job has no organization and the worker cannot enumerate tenants under RLS. Options for the plan that does it: a SECURITY DEFINER function returning organization ids (an RLS exception to justify in rls_policy_exempt.txt’s spirit), or one scheduled job per organization enqueued at provisioning. Plan 9 took neither.
  • Plans 7 and 8. See Deferred until Plans 7/8: the source_kind/source_id posting convention and the two read functions are the whole contract.
  • Plan 10 (frontend). Report endpoints return provisional — show it; money is a string, never parse it to a JS number for display arithmetic; exports are asynchronous (202 → poll GET /v1/reports/exports/{id} → download link valid for PresignTTL).
  • Any plan adding a job kind. Register its worker next to the export worker in cmd/worker/exports.go (rename the file then), give it its own queue in jobs.WorkerConfig, keep its args to ids only (R15’s condition of the rls_exempt.txt entry), and never use UniqueOpts without first proving the advisory lock River then takes is compatible with the audit lock order.

Checked against the brief, spec §10.7 and the real Plan 4 code before execution.

Spec and brief coverage.

  • §10.7 “trial balance · P&L · balance sheet · general ledger detail” → Tasks 7, 8, 9 (+ HTTP in 14). “aged advances · float register” → Deferred D1, D2 (brief’s split; R31). “claim register” is in §10.7 but not in the ROADMAP’s Plan 9 line nor the brief’s scope; it needs Plan 5’s claims and is left to a Plan 5 follow-up (it is a register of documents, not a ledger report — see Needs the user’s decision). “one query layer in platform/reporting that takes a period, an account subtree and dimension filters” → Task 6’s movements/resolveScope (period, path prefix, analytic ids), reused by 7–9. “Export to CSV / XLSX / PDF runs in a River job” → Tasks 2, 3, 10–13, 15, 16.
  • Brief: numbers only from the ledger → R3, Task 6’s SQL, TestMovementsSumBaseAmounts, TestReportingNeverWrites. Read-only → Task 1 (Postgres-enforced), R4. Money as money.Amount, strings on the wire, no floats in XLSX/PDF → Task 10 (<v> exact, TestNoFloatTouchesAnAmount), R22–R25, Task 14’s string assertions. Period/as-of semantics and the year-end close → R5–R7, TestAClosedYearStillShowsItsResult, TestTheProfitIsTheSameBeforeAndAfterTheClose, the four balance-sheet scenarios. Tenant isolation both layers → literal org_id = $1 everywhere (guard run in Tasks 6, 9, 12), report_export RLS (Task 5), isolation tests with presence first (Tasks 5, 6, 12, 13, 14, 15). Every read in a transaction → InReadOnlyTx in every handler and in the job. Permissions reports.* → Task 14 (R26). River in cmd/worker, migrations, rls_exempt.txt, retention lists → Tasks 2, 3, 16 (R14–R16). Object store in Malaysia, Plan 5’s seam shape reused → Task 4 (R21), export.Store’s compile-time proof (Task 12). Pure-Go, maintained, license-compatible libraries → R22 (excelize test-only, with the reason the production path is stdlib), R23 (gopdf + Go fonts), R24 (stdlib CSV). Every export audited (who, report, parameters) → Task 12’s request audit, Task 13’s completion audit, asserted in Tasks 12, 13, 17 (R19). File retained per §11A → R20, REVOKE DELETE, DEPLOY_NOTES object lock. Large reports → R11, R12, Task 9’s streaming visitor and keyset cursor, export caps. Ranges → Global Constraints, R2. Wave-2 §3 lessons → named constraints in 00120, pinned report_export_guard, lock order in finish, bounded waits and t.Cleanup in every River test, sabotage steps in Tasks 1, 2, 5, 6, 7, 8, 9, 10, 13, 14.

Placeholder scan. No “TBD”/“TODO”/“similar to Task N”. Four places name existing code to copy rather than repeating it: Plan 5 Task 4’s MinIO adapter test body (on main, copied unchanged — Task 4 Step 2), modules/accounting’s test harness helpers and permissionLoader, and its decodeStrict family (Tasks 14–15). Each is real code the implementer can open, as Plan 4’s own self-review allowed. The P&L/balance-sheet projection (Task 13) and three of the four report handlers (Task 14) are specified by columns and rows and one fully written sibling rather than repeated in full.

Type consistency. reporting.Range/Scope/AccountRef/StatementRow (Task 6) are used unchanged by 7–9, 12–14. movement, lookup, net (Task 6) are what 7–9 call. GLVisitor/GLAccount/GLLine/GLCursor/GLResult/GeneralLedgerHeader/CountGeneralLedger (Task 9) are what Task 13’s glExport and Task 14’s handler use. render.Writer/Cell/Column/Meta/FormatAmount/ErrRowWidth (Task 10) are what 11 and 13 use; Column.OmitInPDF is set only by Task 13’s GL and statement columns. export.Params field names match ReportExportParams in OpenAPI (Task 15) and the JSON stored in report_export.params. export.Store’s two methods match Plan 5’s documents.Store byte for byte (Task 4’s interface block). jobs.Inserter is satisfied by *river.Client[pgx.Tx] (Task 3’s compile-time line). JobArgs.Kind() = "report_export" is what Task 12’s test reads back from river_job.kind.

Needs the user’s decision (recorded, not blocking):

  1. §10.7’s “claim register” is in the spec but not in the ROADMAP line or the brief; this plan does not build it. It reads claims (documents), not the ledger, so it belongs with Plan 5 or a later reporting plan.
  2. R22: the brief asked for an XLSX library; the production writer is stdlib because excelize cannot stream exact decimals. excelize stays as a test-only reader. If the user prefers excelize in production, the cost is its in-memory model and a lower XLSX cap.
  3. R5: the balance sheet has no exclude_closing flag (a pre-close balance sheet at a closed year’s end). Cheap to add if an auditor asks.
  4. Timezone (open since wave 1): report dates are UTC calendar days and generated_at is UTC; a move to Asia/Kuala_Lumpur changes clock, not this plan.

Item Value
Plan 5’s object-store seam on main when Task 4 ran? Yes
Source of config/objectstore.go, documents/store.go, s3store, documentstest on this branch Adopted unchanged from Plan 5 (expense claims), merged into main via PR #3 and brought into plan-9-reports by the base-merge commit before Task 1
Commit that landed them here Plan 5 original: 69dea34 (Configure the in-Malaysia object store and the OCR switch); base-merge: 5439735 (Merge main into plan-9-reports before Task 1)
Instruction left for Plan 5’s executor Plan 5 Task 2 (object-store half) and Task 4 landed with Plan 9 Task 4 on branch plan-9-reports at 69dea34; verify the files are identical to Plan 5’s specification and skip those steps