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 inplatform/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.mdonmain— §3 (Plan 4’s review lessons: named constraints, pinnedsearch_path, lock order, test hygiene), §4 (records stay in Malaysia; the object store).docs/superpowers/plans/2026-09-21-plan-4-accounting-core.mdand 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.mdonmain— Task 2 (object-store config) and Task 4 (theStoreseam), which Task 4 of this plan adopts or builds (Ruling R21).
Global Constraints
Section titled “Global Constraints”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 editinternal/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–00129only; this plan uses00110–00116(River, generated) and00120–00121;00117–00119are reserved for River upgrades. Provisioning step orders100–109— unused (Ruling R27). Permissionsreports.*only. OpenAPI paths/reports/…only (served under/v1). - Shared files are append-only (wave-1 contract §3):
constants.yamlpermissions list,api/openapi.yaml,internal/app/app.go,internal/app/routes_test.go(composedModules), the blank imports incmd/api,cmd/migrate,cmd/orgadmin,cmd/workerand theirwiring_test.go,internal/kernel/db/retention_test.golists,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.sumconflicts are resolved by re-running each side’sgo getthengo mod tidy, never by hand. - Numbers come only from the ledger (Ruling R3). Every figure is a
SUMoverjournal_line.base_debit/base_creditof rows injournal_entry(every row there is posted: the ledger is append-only and has no draft state), or overanalytic_distribution.base_amountunder 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 arereport_exportrows, their audit rows, River job rows and export files. NoINSERT/UPDATE/DELETEappears ininternal/platform/reportingoutsidereporting/export(Task 6’s guard). - Money is
money.Amount(NUMERIC(19,4)), base currency only, a string on the wire (Moneyschema). Nofloat32/float64value ever holds an amount — not in SQL scanning, not in rendering, not in XLSX or PDF. The only floats allowed inreporting/renderare PDF page geometry insidepdf.go(Ruling R23); Task 10’s guard enforces the rest. - Tenant isolation, both layers. Every SQL statement names
org_id = $1for each org-scoped table it touches (join equalities count;TestEveryTenantQueryCarriesAnExplicitOrgPredicatereads Go literals — write every query as a literal or a same-package string constant, neverfmt.Sprintf), and everyINSERTnamesorg_id.report_exporthasENABLEandFORCE ROW LEVEL SECURITYand anorg_isolationpolicy withUSINGandWITH CHECK; its FKs to org-scoped tables are composite(org_id, x). Every platform function reads the org fromtenancy.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
CHECKandUNIQUEin Plan 9’s own migrations is writtenCONSTRAINT <name> …, and no name ends in_check,_keyor_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_tempand schema-qualifies its tables; River’s one function is pinned byALTER FUNCTIONin its generated migration (Ruling R15).TestEveryFunctionPinsItsSearchPathenforces it. - Every table is in exactly one list in
internal/kernel/db/retention_test.go.report_exportisamendable; River’s tables areoperational. - Every state change writes
audit.Writeon the samepgx.Tx, after every row lock the transaction takes (wave-2 §3 lock order). The lock order in this plan:report_exportrowFOR UPDATE→audit.Write. This plan adds no advisory-lock class, and River is used withoutUniqueOpts, 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; everyDATEhandled in Go is midnight UTC; timestamps are written and rendered in UTC. Nevertime.Now()in code under test. (Whether to move to Asia/Kuala_Lumpur is an open user decision; keep every derivation behindclock.) - Record language (§11A.3). Exports print each account’s record-bearing
name(en/ms), never azhtranslation (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 needledger.MapError(they post nothing). - Every route goes through
httpx.Protectedwith areports.*permission registered in the module’sinit(), and is documented inapi/openapi.yamlin 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.WithTimeoutand clean up int.Cleanup(stop River clients, close pools), so a regression fails instead of hanging. Not.Parallel()in packages that start River clients. - Tests: during development run only the packages you changed (
go test ./internal/platform/reporting/ -count=1).make generateafter any migration, OpenAPI orconstants.yamlchange. One fullgo test -p 2 -count=1 ./...in Task 17 only. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”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 likeaccountingtest),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 moduleinternal/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 namesplatform/reporting; River is infrastructure every later plan’s jobs share, so its configuration lives once in the kernel — movingkernel/jobslater is a mechanical rename. - R2 Ruling: Plan 9’s ranges are migrations
00110–00129, provisioning100–109, permissionsreports.*, paths/v1/reports/…, as the task brief assigns (the wave-1/2 contracts stop at Plan 6’s00109) — 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_linebase amounts (oranalytic_distribution.base_amountunder 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 asInTx, 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) unlessexclude_closing=true, which gives the pre-closing TB. The general ledger always includes them. The balance sheet includes every entry throughas_ofand 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 containingas_ofthroughas_of, closing entries included, so it is 0 at a closed year’s end) andprior_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 datedends_oninto 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 anexclude_closingflag on the balance sheet later. - R6 Ruling: Every report takes calendar dates (
from/toinclusive, oras_of), not period ids; dates need not align to period boundaries and may fall outside any fiscal year; the response carriesprovisional: truewhen any fiscal period overlapping the range (balance sheet: any period starting on or beforeas_of) is stillopen, 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_idvalues (repeatable) must all belong to one axis, each expands to its descendants on that axis, and amounts are the storedanalytic_distribution.base_amountsummed 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 byanalytic.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 itspath) is accepted on TB, P&L and GL detail and refused on the balance sheet; a filtered TB reportsfiltered: true, and itsbalancedis 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 bycode(the storedpathis 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 bycode, 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 carriesopening(balance just before its first line on that page) andclosing(after its last). It includes every semantic (R5). Under an analytic filter a line appears once with its summed share, andtx_debit/tx_creditare null —posted_seqis 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
COUNTwhen the export is requested (422 naming the cap) and again in the job (failed,too_large) — Plan 5’sStore.Puttakes[]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) andlabel(the request locale’s translation viai18n.Load, falling back toname); exports printnameonly — §11A.3: an exported statement is a record and must be en/ms;zhis a UI locale — a Chinese-locale user’s export shows English or Malay account names. - R14 Ruling: River is
github.com/riverqueue/riverv0.47.0 withriverdriver/riverpgxv5. Its schema is vendored into goose migrations00110–00116, one file per River migration version (1–7), generated fromrivermigrate.AllVersions()bygo run ./tools/riverschemaand pinned by a drift test; each file also records its version inriver_migration(the pre-v5 shape for versions 1–4,(line, version)from 5) sorivermigrate.Validateagrees with goose. One file per version because version 4 runsALTER 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 reserved00117–00119. - R15 Ruling: River’s tables stay in schema
publicand are listed indb/rls_exempt.txtwith a justification (they hold no tenant data: a job’s args areorg_idandexport_id, both ids; the tenant record isreport_export, under RLS), and in theoperationalretention list (River’s cleaner deletes finished jobs after 24 h). The generated migration also runsALTER FUNCTION river_job_state_in_bitmask(bit, river_job_state) SET search_path = pg_catalog, public, pg_tempand grantserp_appUSAGEon River’s sequences (00001’s default privileges cover tables only). A separateriverschema 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.JobArgsis 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
InsertTxinside the request’s owndb.InTx, so the export row, its job and its audit row commit or roll back together (§2). The worker holds a client withPollOnly: true(pgbouncer transaction pooling cannot holdLISTEN),FetchPollInterval1 s, queuereportswith 2 workers,JobTimeout15 min, and jobs are inserted withMaxAttempts3 and noUniqueOpts— §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 withtenancy.WithOrgand does every read and write throughInReadOnlyTx/InTxunder RLS. Thereport_exportrow, 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 exportfailed/internalfirst. ABEFORE UPDATEtrigger keeps the request columns fixed and a terminal row final;DELETEis revoked (amendable) — §11A.2: the record of what was exported, by whom, is kept seven years — a stuckqueuedrow is possible only if the final-attempt write itself fails; it is visible in the list. - R19 Ruling: Audit rows:
report_export/requestin the enqueue transaction (actor = the requester; report, format, normalized parameters, job id) andreport_export/completeorfailin 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 withContent-Disposition: attachment, valid for the store’sPresignTTL; 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.Storeinterface{ 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’sdocuments.Store, sodocuments.Store,s3store.Storeanddocumentstest.MemStoreall satisfy it. Task 4 adopts Plan 5’sconfig.ObjectStoreanddocumentsseam 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) inrender/xlsx.go: text as inline strings, money as numeric cells whose<v>isAmount.String()verbatim, one sheet, streamed row by row into the zip.github.com/xuri/excelize/v2v2.11.0 (BSD-3, pure Go, released 2026-07) is used in tests only, to read every generated file back — excelize’sStreamWriter.SetRowwrites a number only fromint/floattypes (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 behindrender.Writerand swaps in one file. - R23 Ruling: PDF uses
github.com/signintech/gopdfv0.38.1 (MIT, pure Go, released 2026-09-12; its one dependencyphpdave11/gofpdiis 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/fpdfwas rejected: last release 2023 and archived.float64appears only as page geometry insiderender/pdf.go; every amount is text fromrender.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/csvwith 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 areAmount.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 as1,234.50— none. - R26 Ruling: Permissions are
reports.read(every report endpoint, and listing, reading and downloading exports) andreports.export(requesting one). Download needs onlyreports.readbecause a reader can already see the same figures on screen — none. - R27 Ruling: Plan 9 adds no provisioning step (
100–109unused): reports need no per-org seed, and the Owner receivesreports.*becauseprovisioning.Rungrants every registered permission — no deployed organization exists yet; one provisioned before this plan would need its Owner role grantedreports.*by hand. - R28 Ruling: Migration
00121addsjournal_entry (org_id, entry_date, posted_seq)andanalytic_distribution (org_id, analytic_account_id)indexes — additive to Plan 4’s tables, plainCREATE INDEXbecause no database is deployed yet (§15’sCONCURRENTLYapplies after the first deployment) — every report filters entries by date and GL detail orders byposted_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 in00120; 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 namedriver_%(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:
balancedon 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
reportingfunction;renderonly 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 Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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). |
New Go packages
Section titled “New Go packages”| 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. |
Modified
Section titled “Modified”| 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 ininternal/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(reuseseedOrgfromintx_test.go, same packagedb_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.ReadOnlytopgx.ReadWrite;TestInReadOnlyTxRefusesWritesgoes red (no error returned). ChangeRepeatableReadtoReadCommitted;TestInReadOnlyTxSeesOneSnapshotgoes red. Restore both; quote both failures in the task report. -
Step 6: Commit
git add internal/kernel/db/readonly.go internal/kernel/db/readonly_test.gogit 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.
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_jobriver_leaderriver_migrationriver_notificationriver_queueAppend 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 intointernal/gen/sqlc/models.go; commit them — nothing imports them). Then:
go test ./internal/kernel/jobs/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1Expected PASS, including TestEveryTableIsTenantIsolated, TestExemptionFilesNameRealTables, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryFunctionPinsItsSearchPath, TestModulesNeverReferenceRLSExemptTables.
-
Step 8: Sabotage once. (a) Add a comment line to
00113_river_v004.sql→TestGeneratedRiverMigrationsAreCurrentred. (b) Delete theALTER FUNCTIONline from the trailer, regenerate →TestEveryFunctionPinsItsSearchPathred namingriver_job_state_in_bitmask. (c) Removeriver_queuefromrls_exempt.txt→TestEveryTableIsTenantIsolatedred. Restore each; quote the three failures. -
Step 9: Commit
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.gogit 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 jobsconst 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 jobsFetchPollInterval time.Duration // 0: 1sLogger *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
git add internal/kernel/jobs/jobs.go internal/kernel/jobs/jobs_test.go go.mod go.sumgit 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/minioat the testcontainers version already ingo.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 configtype ObjectStore struct {Endpoint *url.URL // scheme http|https, host[:port], no pathRegion string // default "us-east-1"Bucket stringAccessKey stringSecretKey stringPresignTTL time.Duration // default 5m, 1m..15m}func ObjectStoreFromEnv() (ObjectStore, error)package documentstype 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 s3storefunc New(cfg config.ObjectStore) (*Store, error) // does not dialfunc (s *Store) EnsureBucket(ctx context.Context) errorvar _ documents.Store = (*Store)(nil)package documentstestfunc NewMemStore() *MemStorefunc (m *MemStore) Upload(key string, body []byte)func (m *MemStore) Object(key string) ([]byte, bool)func (m *MemStore) PresignedHeaders(key string) map[string]stringvar _ 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.
- If both exist on
-
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 literalos.Getenv("…")calls (TestEnvReadsHaveLiteralArguments). Endpoint:url.Parse, schemehttp/https, non-empty host, path""or"/", no query. Secret: refuse any value startingREPLACE_MEwith “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 andErrObjectMissingabove, 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:PresignedGetObjectwithreqParamsresponse-content-disposition=attachment; filename="<sanitized>"(strip"and control characters).Open:GetObjectthenStat();minio.ToErrorResponse(err).Code == "NoSuchKey"→ErrObjectMissing.Put:PutObject(ctx, bucket, key, bytes.NewReader(body), int64(len(body)), minio.PutObjectOptions{ContentType: ct}).EnsureBucket:BucketExists→MakeBucketwith the region.documentstest.MemStore: a mutex-guardedmap[string][]byteplus the recorded presigned headers;PresignPutrecords headers and returns"mem://put/" + key;PresignGetreturns"mem://get/" + key + "?filename=" + url.QueryEscape(filename);Uploadstores bytes for a key that had aPresignPut;Putstores unconditionally.- Append to
.env.examplePlan 5 Task 2 Step 4’s object-store block (the sixOBJECT_STORE_*lines and their comments; notOCR_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 fourOBJECT_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
mainat<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,TestCopyingTheExampleDoesNotBootstay green.
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.mdgit 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(appendreport_exporttoamendable),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), triggerreport_export_guardraisingCONSTRAINT = 'report_export_immutable'. -
Step 1: Write the failing test —
internal/platform/reporting/export/schema_test.go(packageexport_test; seed viaaccountingtest.New(t)ande.SeedMember; write throughe.H.Appinside a transaction that setsapp.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 StatementEndCREATE TRIGGER report_export_guard BEFORE UPDATE ON report_export FOR EACH ROW EXECUTE FUNCTION report_export_guard();
-- +goose DownDROP 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 DownDROP INDEX analytic_distribution_account_idx;DROP INDEX journal_entry_date_seq_idx;- Step 5: Retention list and generate. Append to
amendableinretention_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 (includingTestEveryTableIsTenantIsolated,TestEveryTenantForeignKeyIsCompositeOnOrgID,TestEveryFunctionPinsItsSearchPath, the retention test). -
Step 7: Sabotage once. Comment out
REVOKE DELETE→TestAnExportCannotBeDeletedand the retention test red. RemoveOLD.status <> 'queued' OR→TestAnExportMovesOnceFromQueuedToATerminalStatered. Restore; quote both. -
Step 8: Commit
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.gogit 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 reportingtype Range struct{ From, To time.Time } // inclusive calendar days, midnight UTCfunc (r Range) Validate() error // ErrInvalidRange when From > To or either is zerotype Scope struct {AccountID *uuid.UUID // subtree root; nil = whole chartAnalyticAccountIDs []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) errortype AccountRef struct {ID uuid.UUIDParentID *uuid.UUIDCode, Name, Type stringDepth intPostable bool}type StatementRow struct {Account AccountRefHeader bool // !Account.PostableAmount 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) AccountReffunc (cv chartView) postableByCode() []chart.Accountfunc (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 keyfunc (m movement) net() money.Amount // Debit − Creditfunc 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.Timepackage reportingtesttype Options struct{ CloseFY2026 bool }type Golden struct {Env *accountingtest.EnvBooks accountingtest.BooksMember uuid.UUIDShareCapital, Office, Stationery chart.AccountEntries map[string]uuid.UUID // "E1".."E11", and "CLOSE" when closedFY2027 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(packagereporting; 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 postedjournal_linebase amounts (Ruling R3); every exported function takes apgx.Txits caller opened withdb.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, includingTestEveryTenantQueryCarriesAnExplicitOrgPredicateover the new SQL constants. -
Step 9: Sabotage once. (a) Remove
AND a.org_id = $1frommovementsSQL→ the tenant-predicate guard red. (b) InanalyticMovementsSQLreplaces.sharewithl.base_debitin the debit CASE →TestMovementsByAnalyticShareUseTheStoredSharered. (c) Add a string"DELETE FROM account"totree.go→TestReportingNeverWritesred. Restore; quote all three. -
Step 10: Commit
git add internal/platform/reportinggit commit -m "Add the reporting query layer and the golden books
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 7: Trial balance and profit and loss
Section titled “Task 7: Trial balance and profit and loss”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 RangeAccountID *uuid.UUID // subtree (Ruling R9)ExcludeClosing bool // pre-closing TB (Ruling R5)}type TrialBalanceRow struct {Account AccountRefOpeningDebit, OpeningCredit money.Amount // net, one side non-zeroDebit, Credit money.Amount // movement in the rangeClosingDebit, ClosingCredit money.Amount // net, one side non-zero}type TrialBalanceTotals struct {OpeningDebit, OpeningCredit, Debit, Credit, ClosingDebit, ClosingCredit money.Amount}type TrialBalanceReport struct {Params TrialBalanceParamsCurrency stringProvisional boolFiltered boolRows []TrialBalanceRowTotals TrialBalanceTotalsBalanced bool}func TrialBalance(ctx context.Context, tx pgx.Tx, p TrialBalanceParams) (TrialBalanceReport, error)type ProfitAndLossParams struct {Range RangeScope Scope}type ProfitAndLossReport struct {Params ProfitAndLossParamsCurrency stringProvisional, Filtered boolIncome, Expenses []StatementRowTotalIncome, 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(reusereadTxfrom 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.gowith 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: falseinProfitAndLoss→TestAClosedYearStillShowsItsResultred (net profit 0). Restore; quote it. -
Step 6: Commit
git add internal/platform/reporting/trialbalance.go internal/platform/reporting/profitandloss.go \ internal/platform/reporting/tree.go internal/platform/reporting/*_test.gogit commit -m "Add the trial balance and the profit and loss, closed years included
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 8: Balance sheet
Section titled “Task 8: Balance sheet”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 helpersreadTx,stmt);fiscal.YearContaining,fiscal.ErrNotFound. -
Produces:
type BalanceSheetParams struct{ AsOf time.Time }type BalanceSheetReport struct {Params BalanceSheetParamsCurrency stringProvisional boolAssets, Liabilities, Equity []StatementRowCurrentYearEarnings, PriorUnclosedEarnings money.Amount // Ruling R5TotalAssets, TotalLiabilities money.AmountTotalEquity money.Amount // equity accounts + both earnings linesBalanced 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: trueto the asset/liability/equitymovements→TestTheBalanceSheetAtAClosedYearEndred (retained earnings missing, sheet still balances only by accident of the earnings line — read the failure). Restore; quote it. -
Step 6: Commit
git add internal/platform/reporting/balancesheet.go internal/platform/reporting/balancesheet_test.gogit 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 = 500MaxGLPage = 2000)type GeneralLedgerParams struct {Range RangeScope 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 AccountRefOpening money.Amount}type GLLine struct {AccountID uuid.UUIDEntryID uuid.UUIDEntryNo stringEntryDate time.TimeJournalCode stringSemantic stringDescription, Memo stringLineNo intDebit, Credit money.Amount // base, or the analytic share (Ruling R8)Balance money.Amount // running, debit-positiveCurrency stringTxDebit, TxCredit *money.Amount // nil under an analytic filter (Ruling R11)SourceKind *stringSourceID *uuid.UUIDReversalOfID *uuid.UUID}type GLVisitor interface {Account(GLAccount) errorLine(GLLine) error}type GLCursor struct {Code stringAccountID uuid.UUIDEntryDate time.TimePostedSeq int64LineNo int}func (c GLCursor) Encode() string // base64url(JSON)func DecodeGLCursor(s string) (GLCursor, error) // ErrBadCursortype GLResult struct {Currency stringProvisional, Filtered boolNext *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.AccountIDprefix addition →TestPagesCarryTheirOpeningBalancered (page 2 opens at 0). Restore; quote it. -
Step 7: Commit
git add internal/platform/reporting/generalledger.go internal/platform/reporting/cursor.go internal/platform/reporting/generalledger_test.gogit 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 byxlsx_test.go)
Interfaces:
-
Consumes:
money.Amount. -
Produces:
package rendertype Format stringconst (FormatCSV Format = "csv"FormatXLSX Format = "xlsx"FormatPDF Format = "pdf")func (f Format) Valid() booltype Cell struct {Text stringAmount *money.Amount // non-nil: a money cellBold bool}func Text(s string) Cellfunc Money(a money.Amount) Cellfunc Bold(c Cell) Celltype Column struct {Title stringNumeric bool // a money column: right-aligned in PDFWeight int // relative PDF width, >= 1OmitInPDF 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 tableFooter string // PDF page footerLandscape bool // PDF orientation}type Writer interface {Begin(m Meta, cols []Column) errorRow(cells []Cell) error // len(cells) <= len(cols); missing trailing cells are blankFinish() ([]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) stringContent 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 returnserrors.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, thengo 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.gowritestrconv.FormatFloat(…)of a parsed amount →TestNoFloatTouchesAnAmountred (and the exact-<v>assertion if it compiles). (b) RemovesafeTextfromRow→TestCSVIsExactAndSafered. Restore; quote both. -
Step 8: Commit
git add internal/platform/reporting/render go.mod go.sumgit commit -m "Render report tables as CSV and XLSX with exact decimal cells
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 11: render — PDF
Section titled “Task 11: render — PDF”Files:
- Create:
internal/platform/reporting/render/pdf.go - Test:
internal/platform/reporting/render/pdf_test.go(packagerender, 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/imageforfont/gofont/goregularandfont/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 (includingTestNoFloatTouchesAnAmount, which skips onlypdf.go). -
Step 5: Commit
git add internal/platform/reporting/render go.mod go.sumgit 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, ErrUnknownReportfunc (p Params) Range() reporting.Range // after Validatefunc (p Params) AsOfDate() time.Time // after Validatefunc (p Params) Scope() reporting.Scopetype Request struct {Report stringFormat render.FormatParams Params}type Export struct {ID uuid.UUIDReport stringFormat render.FormatParams ParamsStatus stringRequestedBy uuid.UUIDRequestedAt time.TimeRiverJobID int64FinishedAt *time.TimeObjectKey *stringContentType *stringByteSize *int64SHA256 []byteRowCount *int64FailureCode *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) // ErrNotFoundfunc List(ctx context.Context, tx pgx.Tx, limit, offset int) ([]Export, error) // newest first; limit 1..100, default 50var (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 int64Format 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, ¶ms, &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. (TestReportingNeverWritesskipsexport/by design; the tenant-predicate guard covers its SQL.) -
Step 6: Commit
git add internal/platform/reporting/exportgit 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’srender.*; Task 12’sExport,Get,JobArgs,MaxLines,Store,TooLargeError;db.InReadOnlyTx,db.InTx;company.Get;chart.GetAccount;documentstest.MemStore(tests). -
Produces:
type RunDeps struct {DB *db.DBStore StoreClock clock.ClockLogger *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) errortype Worker struct {river.WorkerDefaults[JobArgs]Deps RunDeps}func (w *Worker) Work(ctx context.Context, job *river.Job[JobArgs]) errorfunc (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(reusesworld,newWorld,create,get,tbFY2026from 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 buildsrender.Metaand columns, callsBegin, 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.goandworker.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 infinish→TestRunningTwiceDoesNothingTheSecondTimered (the second run’s UPDATE hitsreport_export_immutable). (b) Delete thepermanentbranch inRun→TestAnExportThatGrewPastItsCapFailsred (the job returns an error and the export stays queued). Restore; quote both. -
Step 7: Commit
git add internal/platform/reporting/exportgit 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(appendreports.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/reportsconst (PermissionRead = "reports.read"PermissionExport = "reports.export")type Deps struct {DB *db.DBClock clock.ClockJobs jobs.Inserter // Task 15's export requestsStore export.Store // Task 15's downloadsPresignTTL 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 keyfunc 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 keyfunc 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. Copyinternal/modules/accounting/chart_handlers_test.go’sgrant,mint,do,decode,discardandalwaysLiveverbatim (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 ofmodules/accounting(thin shell; errors recorded withc.Error; every read inInReadOnlyTx, Ruling R4).Depsas above.Register: the four routes above throughhttpx.Protected(g, p, …)with apermissionLoadercopied frommodules/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.Problempasses through; anything else →apperr.New(apperr.KindInternal, "the report could not be produced")(the cause is logged byhttpx.Errors, as inmodules/accounting).report_handlers.go— DTOs (snake_case JSON;money.Amountfields marshal as strings; every slice initialised withmake(…, 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:inapi/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: theReportAccountproperties plusopening_debit,opening_credit,debit,credit,closing_debit,closing_credit(Money).TrialBalance:from,to(date),currency,provisional,filtered,exclude_closing(boolean),rows(array ofTrialBalanceRow),totals(object: the six Money fields),balanced(boolean).StatementRow: theReportAccountproperties plusheader(boolean),amount(Money).ProfitAndLoss:from,to,currency,provisional,filtered,income,expenses(arrays ofStatementRow),total_income,total_expenses,net_profit(Money).BalanceSheet:as_of,currency,provisional,assets,liabilities,equity(arrays ofStatementRow),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: theReportAccountproperties plusopening,closing(Money),lines(array ofGeneralLedgerLine).GeneralLedger:from,to,currency,provisional,filtered,accounts(array ofGeneralLedgerSection),next_cursor(string, nullable).
Write them flat (no allOf), matching the existing schemas’ style; make generate.
- Step 6: Compose.
internal/app/app.go: importinternal/modules/reports; appendReports reports.DepstoDeps; appendreports.Register(api, deps.Reports)afteraccounting.Register.internal/app/routes_test.go: append"reports": {reports.PermissionRead, reports.PermissionExport},tocomposedModules.cmd/api/main.go: beforeapp.Router, build the object store and the River inserter (if Plan 5 already buildsstorein 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.goandcmd/orgadmin/main.go: append the blank import_ "github.com/mcom/erp/internal/modules/reports"; in theirwiring_test.goappend the package path and"reports.read", "reports.export", // modules/reportsto 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 noriver_table). -
Step 8: Sabotage once. Register
general-ledgerunderPermissionExport→routes_test.gored; dropallowQueryfrom the balance-sheet handler →TestFiltersAReportDoesNotTakeAreRefusedred. Restore; quote both. -
Step 9: Commit
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/orgadmingit 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’sDeps, 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. CopydecodeStrictand its helpers (decodeProblem,wrongTypeField,failingField,probeFields) frominternal/modules/accounting/http.gointohttp.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 (202with aLocationheader;409on download with aProblem;404on an id of no export of the caller’s organization), and schemas:ReportExportParams: optionalfrom,to,as_of(date),account_id(uuid),analytic_account_ids(array of uuid),exclude_closing(boolean);additionalProperties: false.ReportExportRequest: requiredreport(enum trial_balance|profit_and_loss|balance_sheet|general_ledger),format(enum csv|xlsx|pdf),params(ReportExportParams).ReportExport: requiredid,report,format,params,status(enum queued|succeeded|failed),requested_by,requested_at(date-time),filename; nullablefinished_at,content_type,byte_size(integer),sha256(hex string),row_count(integer),failure_code(enum too_large|invalid_parameters|internal).ReportExportList:exports(array ofReportExport).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
git add internal/modules/reports api/openapi.yaml internal/gen/apigit commit -m "Request, list, read and download report exports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 16: The worker runs River
Section titled “Task 16: The worker runs River”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(packagemain; 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”:- River’s schema ships as goose migrations
00110–00116; runmigrate up(the privileged migrate job) before starting api or worker. Never run River’s ownriver migrate-upin production — goose owns the schema. - The worker now needs the four
OBJECT_STORE_*settings, like the api, and refuses to boot without them. - The worker polls (
PollOnly), so it works behind pgbouncer in transaction mode; expect up to a second before an export starts. - 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. - River deletes finished job rows after 24 h; the record of an export is
report_exportandaudit_log, which are kept.
- River’s schema ships as goose migrations
-
Step 5: Run.
go test ./cmd/worker/ -count=1→ PASS (the new test andTestWorkerHasTheLedgerPoster).go build ./cmd/.... -
Step 6: Commit
git add cmd/worker docs/DEPLOY_NOTES.mdgit 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.
make generate && git diff --exit-code # generated files are currentmake lintmake buildgo test -p 2 -count=1 ./...All green. Quote the summary lines in the task report.
- Step 5: Commit
git add internal/modules/reports/e2e_test.go docs/ROADMAP.mdgit commit -m "Prove exports match the screen; tick Plan 9's roadmap lines
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Deferred until Plans 7/8
Section titled “Deferred until Plans 7/8”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.
D1: Aged advances — needs Plan 7
Section titled “D1: Aged advances — needs Plan 7”What Plan 7 must expose (and must be true of its postings):
- Every advance’s ledger effect posts through Plan 4’s engine with
source_kind = "advance"andsource_id = <advance id>, on the account resolved from control kindemployee_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. - 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.UUIDReference string // e.g. "AD-2026-0007"HolderPartyID uuid.UUIDHolderName string // record-language legal nameIssuedOn time.Time // the payout's entry dateDueOn 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) - 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.
D2: Float register — needs Plan 8
Section titled “D2: Float register — needs Plan 8”What Plan 8 must expose (and must be true of its postings):
- 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_kindnaming the movement’s document kind andsource_idits id, so the float’s register is the general ledger of that one account. - A read-only function in
internal/platform/pettycash:type FloatRef struct {ID uuid.UUIDCode stringHolderMemberID uuid.UUIDHolderName string // record-language legal nameAccountID uuid.UUID // the float's cash accountOpenedOn time.TimeClosedOn *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) - The §10.6 “Float integrity” and “Float commitment” checks registered with
reconcile.Register(the register prints their pass/fail, neverfloat.cash_balanceitself — 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.
Parallel groups
Section titled “Parallel groups”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).
Notes for later plans
Section titled “Notes for later plans”- Plan 5 (expense claims). (1) Its Task 2 (object-store half) and Task 4 may already be on
mainvia 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 namedriver_%: 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 incmd/worker/exports.go’sstartExportWorkersibling — 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 DEFINERfunction returning organization ids (an RLS exception to justify inrls_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_idposting 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 → pollGET /v1/reports/exports/{id}→ download link valid forPresignTTL). - 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 injobs.WorkerConfig, keep its args to ids only (R15’s condition of therls_exempt.txtentry), and never useUniqueOptswithout first proving the advisory lock River then takes is compatible with the audit lock order.
Self-Review Notes
Section titled “Self-Review Notes”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/reportingthat takes a period, an account subtree and dimension filters” → Task 6’smovements/resolveScope(period,pathprefix, 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 asmoney.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 → literalorg_id = $1everywhere (guard run in Tasks 6, 9, 12),report_exportRLS (Task 5), isolation tests with presence first (Tasks 5, 6, 12, 13, 14, 15). Every read in a transaction →InReadOnlyTxin every handler and in the job. Permissionsreports.*→ Task 14 (R26). River incmd/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 in00120, pinnedreport_export_guard, lock order infinish, bounded waits andt.Cleanupin 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):
- §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.
- 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.
- R5: the balance sheet has no
exclude_closingflag (a pre-close balance sheet at a closed year’s end). Cheap to add if an auditor asks. - Timezone (open since wave 1): report dates are UTC calendar days and
generated_atis UTC; a move to Asia/Kuala_Lumpur changesclock, not this plan.
Reconciliation record (filled by Task 4)
Section titled “Reconciliation record (filled by Task 4)”| 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 |