Skip to content

Bank Statement Import and Reconciliation Implementation Plan

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

Goal: A finance clerk links each of the organization’s bank accounts to its GL bank account, imports the bank’s statements (MT940, or CSV through a per-account column mapping) as immutable, hashed, balance-chained records, has them auto-matched against the ledger lines on that GL account (amount + date window + reference), matches and unmatches the rest by hand with the history kept, books bank charges and interest from an unmatched line through ledger.Post, and reads a reconciliation statement per statement date — balance per bank, outstanding book items, unrecorded bank items, balance per books — that the owner locks once its difference is zero.

Architecture: One platform package and one module, the payables shape. internal/platform/banking owns every rule: the pure MT940 and CSV parsers, bank accounts (a role on one account of control kind bank), statements and their lines, match groups with append-only unmatch, the pure auto-matcher, the journal-from-line (the one place it calls ledger.Post), and the reconciliation computation and lock. internal/modules/banking is the HTTP shell. The web gains web/src/features/banking. Nothing here is a workflow kind or a posting rule; accounting/ is untouched except that ledger.Post is called the way postJournalEntry calls it. The §10.6 reconciliation set (internal/platform/accounting/reconcile) is a different thing and is not touched or named.

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

Spec: docs/superpowers/specs/2026-09-27-bank-reconciliation-design.md (D1–D16)

Every task’s requirements implicitly include this section.

  • Migrations 00350–00369 only. This plan uses 00350–00353. Task 2 registers {350, 369, "Phase 2 bank reconciliation"} in internal/kernel/db/migrationranges_test.go in the same commit as 00350.
  • Money never float. money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire (the OpenAPI Money schema). Compare with Cmp, never ==. No float64 / strconv.ParseFloat anywhere in internal/platform/banking, internal/modules/banking or web/src/features/banking (web/src/drift/nofloat.test.ts scans the web). Web sums use sumMoney / compareMoney from web/src/lib/money.ts.
  • Every new table has org_id UUID NOT NULL REFERENCES organization (id), ENABLE and FORCE ROW LEVEL SECURITY, a policy named org_isolation with USING and WITH CHECK on current_setting('app.current_org', true)::uuid, a CONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite (org_id, …) foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Named constraints. Every CHECK/UNIQUE/FOREIGN KEY is CONSTRAINT <table>_<rule>; no name ends in _check, _key or _excl (TestEveryConstraintIsNamed). A trigger’s refusal is RAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name (banking.MapError).
  • Pinned functions. Every function is SET search_path = pg_catalog, public, pg_temp and schema-qualifies every table (TestEveryFunctionPinsItsSearchPath). No SECURITY DEFINER. Commit-time (deferred) checks refuse to run under any organization but the row’s own (the ledger’s rule, 00052).
  • Every SQL statement names org_id = $n for each org-scoped table it touches, join equalities included, and every INSERT names org_id (TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org from tenancy.OrgFromContext; nothing takes an org argument.
  • Retention. Every new table is in exactly one list of internal/kernel/db/retention_test.go: bank_account, bank_statement are amendable (REVOKE DELETE); bank_statement_line, bank_match, bank_match_item, bank_unmatch, bank_reconciliation, bank_reconciliation_item are appendOnly (REVOKE UPDATE, DELETE).
  • The ledger is append-only and modules never write lines. The only ledger write in this plan is ledger.Post in internal/platform/banking/journal.go with Semantic: ledger.SemanticManual (spec D9). No SQL in this plan inserts into journal_*.
  • Accounting reacts to semantics. Nothing here compares a workflow state_key; banking reads journal_line generically by account_id, never a module’s document tables.
  • Lock order, one for the whole plan (spec D14): bank_account row FOR UPDATE (via lockBankAccount) → ledger.Post (period, sequence, its audit) → banking rows → banking’s audit.Write, last. Every banking write takes the bank-account lock first; the DB guards take it too (a re-lock by the same transaction is free).
  • No nested db.InTx. Platform functions take (ctx, tx pgx.Tx, …); only handlers open transactions. Every handler passes db.InTx’s error through banking.MapError (which falls through to ledger.MapError) before mapping it, because the balance/group/total checks are deferred triggers that fire at COMMIT.
  • Business date is clock.TodayUTC(c); every DATE handled in Go is midnight UTC; never time.Now() in code under test.
  • Record-bearing text the business writes (void reason, unmatch reason, journal description) is English or Bahasa Malaysia: recordtext.Check in Go and a named CHECK (… !~ '[぀-ヿ㐀-䶿一-鿿가-힯]') in SQL (written out in full at every use). Bank statement text is stored verbatim and is exempt (spec D3) — do not add the CHECK to bank_statement_line.
  • Errors are RFC 7807. Handlers c.Error(apperr.New(kind, detail)) and return. Platform code returns sentinel or typed errors, never *apperr.Problem. A field refusal is 422 with detail field=<name> rule=<code>: <sentence> (banking.FieldError, the payables.FieldError shape).
  • Every route goes through httpx.Protected with a banking.* permission and is documented in api/openapi.yaml in the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces). api/public_routes.txt gains nothing. make generate after every OpenAPI or constants.yaml change; generated files are committed and git status is clean after it.
  • i18n. Every UI string is a key in web/src/lib/i18n/locales/{en,ms,zh}/banking.json; web/src/drift/i18nparity.test.ts requires identical keys and placeholders in all three. Malay is genuine Malay (use the strings given in Task 10), never English copied into ms.
  • Presence before absence (AGENTS.md). Every refusal test shows the accepted case first. Every guard is watched failing once: each task with a guard has a Sabotage step — break the guard, run the test, read the red message, restore — and the commit message records which guard was watched and the failing test’s name.
  • Tests: testdb.Start (through expensestest.New, via bankingtest.New) per test; run only the touched packages while iterating (go test ./internal/platform/banking/... -count=1); one full go test -p 2 -count=1 ./... in Task 13 only.
  • Never bare git stash. If work must be set aside, a WIP commit.
  • E2E runs on lane 4 only: Postgres 56452, MinIO 59140, API 3140, Vite 5195 — never the user’s dev stack (3000/5173/55432/59000).
  • Git commits end with: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Where the spec is silent or cannot be built as written. Format: Ruling: <decision> — <why>.

  • B1 Ruling: Package names internal/platform/banking (+ banking/bankingtest, a non-test fixture package like payablestest) and internal/modules/banking. Exported types are BankAccount, Statement, StatementLine, BookLine, Match, Reconciliation — never Check, Result or Run, which are reconcile’s.
  • B2 Ruling: A bank account’s currency is derived, not input: the GL account’s currency, or the base currency when that is null (spec D1). There is then nothing to refuse in Go except a GL account that is not an active postable bank account (422 field=account_id rule=bank_account) or already linked (409 already_linked).
  • B3 Ruling: account_number is normalised in Go (spaces and - removed) and must then match ^[0-9]{4,34}$ (422 field=account_number rule=digits).
  • B4 Ruling: Statement lines are inserted with one INSERT … SELECT … FROM unnest($arrays) statement, not COPY — COPY FROM is refused on a table with row-level security for a non-bypass role.
  • B5 Ruling: Zero-amount statement lines are skipped by both parsers (the raw file keeps them); bank_statement_line_amount_nonzero backs it. A zero line moves no balance and could never be in a balanced group with a non-zero book line.
  • B6 Ruling: Import runs auto-match inside its own transaction (after inserting the lines, before its audit row) through the unexported autoMatch, and records the created match ids in its one audit row. RunAutoMatch (the on-demand route) writes its own audit row.
  • B7 Ruling: A statement line’s date for reconciliation and cleared_on is its statement’s ends_on; its own booked_on is used only by the auto-matcher’s date window (spec D6, D11).
  • B8 Ruling: bank_match.cleared_on is computed in Go and verified by the deferred trigger bank_match_valid, not computed by the database — so the Go value (used for the lock check before insert) and the stored one cannot disagree silently.
  • B9 Ruling: The statement void guard is written in 00351 checking only “latest non-voided”; 00352 replaces the function (CREATE OR REPLACE) adding “no active match” and “after the latest lock”, because bank_match and bank_reconciliation do not exist before 00352. Its Down restores the 00351 body.
  • B10 Ruling: Book lines offered for matching are every journal_line on the GL account whatever its entry’s semantic (accrual, payout, manual, reversal, year-end close cannot hit a balance-sheet bank account but is not excluded by code). A reversed entry and its reversal are two outstanding lines until contra-matched (spec D6).
  • B11 Ruling: GET /banking/accounts/{id}/book-lines takes from, to (dates, default the last 90 days to today) and unmatched (bool, default true); capped at 2000 rows, ordered by entry date then entry number; truncated: true when capped.
  • B12 Ruling: GET /banking/statements/{id} returns every line with match ({id, kind, rule, cleared_on} of its active match, or null) and match_history ([{id, kind, matched_at, unmatched_at, reason}]); statements have at most 10 000 lines (spec D5), so no pagination.
  • B13 Ruling: The journal-from-line’s description is required (422 field=description rule=required); the default the web pre-fills is the Malay/English phrase “Caj bank” / “Bank charges” by sign (negative) or “Faedah bank” / “Bank interest” (positive) in the record locale the user picks — never statement text, which may not be en/ms.
  • B14 Ruling: POST /banking/accounts/{id}/reconciliations returns the locked Reconciliation (201); the live GET returns locked non-null when that date is locked, and then serves the snapshot (header + items), not a recomputation.
  • B15 Ruling: The reconciliation date picker lists the account’s non-voided statements’ ends_on (from the statements list); as_of not equal to one is 422 field=as_of rule=statement_date.
  • B16 Ruling: Audit rows: bank_account (create, update), bank_statement (import — metadata and line count, not the file bytes; void), bank_match (match, auto_match with the ids, unmatch, opening_clearance), bank_reconciliation (lock). No activity-feed rows.
  • B17 Ruling: Nav: the existing accounting group gains one item, Bank accounts → /banking (banking.read); the account page is /banking/$id with tabs statements | matching | reconciliation in the search param tab.
  • B18 Ruling: scripts/dev-stack.sh reads PG_PORT, MINIO_PORT, API_PORT, WEB_PORT and STACK (container-name suffix and state dir) from the environment with today’s values as defaults, and web/playwright.config.ts reads E2E_WEB_PORT/E2E_API_PORT; if another Phase 2 item has already done this, reuse theirs and skip that step. web/vite.config.ts reads API_PORT for its proxy target (default 3000).

File Task Responsibility
00350_bank_account.sql 2 bank_account, its guard (bank_account_gl_is_bank, bank_account_identity_fixed).
00351_bank_statement.sql 3 bank_statement, bank_statement_line, chain/immutability/void guard, line guard, deferred balance check, file-once index.
00352_bank_match_reconciliation.sql 4 bank_match, bank_match_item, bank_unmatch, bank_reconciliation, bank_reconciliation_item, their guards; replaces the statement void guard (B9).
00353_banking_role_grants.sql 8 Existing Owners get the six banking.* keys, existing Finance roles five (not reconciliation.lock); perm_version bump.
Path Task(s) Responsibility
internal/platform/banking/ 1–7 doc.go, errors.go, text.go, mapping.go, mt940.go, csv.go, account.go, statement.go, match.go, automatch.go, journal.go, reconciliation.go
internal/platform/banking/bankingtest/ 2 (+3–7 add files) Test world on expensestest.Env: GL bank account, bank account, MT940 builder, book-line poster.
internal/modules/banking/ 8, 9 permissions.go, routes.go, http.go, errors.go, account_handlers.go, statement_handlers.go, match_handlers.go, reconciliation_handlers.go
web/src/features/banking/ 10–12 API hooks, pages, dialogs.
Path Task Change
internal/kernel/db/migrationranges_test.go 2 {350, 369, "Phase 2 bank reconciliation"}
internal/kernel/db/retention_test.go 2, 3, 4 New tables into their lists.
constants.yaml (+ generated vocab) 8 Six banking.* permissions.
api/openapi.yaml (+ internal/gen/api, web/src/lib/api/schema.d.ts) 8, 9 /banking/… paths and schemas.
internal/app/app.go, internal/app/routes_test.go (composedModules), cmd/{api,migrate,orgadmin,worker}/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go 8 Compose the module (exactly the files AP Task 12 touched for payables).
web/src/app/shell/nav.ts, web/src/app/routes.ts 10 One nav item; feature routes.
scripts/dev-stack.sh, web/playwright.config.ts, web/vite.config.ts 13 Lane ports (B18).
web/e2e/seed.ts, web/e2e/support.ts, web/e2e/banking.spec.ts 13 Finance’s banking set; the e2e.
docs/ROADMAP.md, docs/DEPLOY_NOTES.md 13 Tick the line; deploy notes.

Task 1: Pure parsers — text decoding, CSV mapping, MT940, CSV

Section titled “Task 1: Pure parsers — text decoding, CSV mapping, MT940, CSV”

Files:

  • Create: internal/platform/banking/{doc.go,errors.go,text.go,mapping.go,mt940.go,csv.go}
  • Test: internal/platform/banking/{mt940_test.go,csv_test.go,mapping_test.go} and fixtures internal/platform/banking/testdata/{two_messages.sta,wrapped.sta,latin1.csv}

Interfaces:

  • Consumes: money.Amount, money.Parse.
  • Produces:
errors.go
type FieldError struct{ Field, Rule, Detail string } // "field=%s rule=%s: %s"
func fieldErr(field, rule, detail string) error
// ParseError is a refusal of the file's content; the handler maps it to 422 field=file.
type ParseError struct{ Rule string; Line int; Detail string } // "field=file rule=%s: line %d: %s"
// text.go
func decodeText(b []byte) string // valid UTF-8 as is (BOM stripped), else ISO-8859-1 byte→rune
// mapping.go
type CSVMapping struct {
Delimiter string `json:"delimiter"` // "," ";" "\t" "|"
HeaderRows int `json:"header_rows"` // 0..20
FooterRows int `json:"footer_rows"` // 0..20
DateColumn int `json:"date_column"` // 1-based
DateFormat string `json:"date_format"` // key of DateFormats
ValueDateColumn int `json:"value_date_column"` // 0 = none
DescriptionColumns []int `json:"description_columns"` // ≥ 1
ReferenceColumn int `json:"reference_column"` // 0 = none
AmountColumn int `json:"amount_column"` // signed; 0 when debit/credit
DebitColumn int `json:"debit_column"`
CreditColumn int `json:"credit_column"`
DecimalComma bool `json:"decimal_comma"`
BalanceColumn int `json:"balance_column"` // 0 = none
NewestFirst bool `json:"newest_first"`
}
var DateFormats = map[string]string{
"DD/MM/YYYY": "02/01/2006", "DD-MM-YYYY": "02-01-2006", "DD.MM.YYYY": "02.01.2006",
"DD/MM/YY": "02/01/06", "YYYY-MM-DD": "2006-01-02", "DD MMM YYYY": "02 Jan 2006", "MM/DD/YYYY": "01/02/2006",
}
func (m CSVMapping) Validate() error // FieldError field=csv_mapping.<json name>
// parse outputs
type ParsedLine struct {
BookedOn time.Time
ValueOn *time.Time
Amount money.Amount // signed, non-zero (B5)
Description string
Reference string
BankReference string
TransactionCode string
}
type Parsed struct {
Account string // MT940 :25:, "" for CSV
Currency string // MT940 only
StartsOn, EndsOn time.Time // MT940 only (zero for CSV: the request supplies them)
Opening, Closing *money.Amount // MT940 always; CSV only with a balance column
Lines []ParsedLine
}
const MaxLines = 10000
func ParseMT940(b []byte) (Parsed, error)
func ParseCSV(b []byte, m CSVMapping) (Parsed, error)
  • Step 1: Failing tests. mt940_test.go (package banking, internal — the parsers are pure):
const twoMessages = `:20:STMT1
:25:MBBEMYKL/514012345678
:28C:00001/001
:60F:C260901MYR1000,00
:61:2609020902D250,00NTRFSP-2026-0001//BR123
PAYMENT TO SUPPLIER
:86:IBG ACME SDN BHD SP-2026-0001
REF 99
:61:260903C1500,50NTRFNONREF
:86:DUITNOW 陈大文
:62M:C260903MYR2250,50
-
:20:STMT1
:25:MBBEMYKL/514012345678
:28C:00001/002
:60M:C260903MYR2250,50
:61:2609300930D12,50NCHGNONREF
:86:SERVICE CHARGE
:61:2612311231RD5,00NCHG//REV1
:62F:C261231MYR2243,00
-`
func TestParseMT940TwoMessagesChainIntoOneStatement(t *testing.T) {
p, err := ParseMT940([]byte(twoMessages))
require.NoError(t, err)
require.Equal(t, "MBBEMYKL/514012345678", p.Account)
require.Equal(t, "MYR", p.Currency)
require.Equal(t, day("2026-09-01"), p.StartsOn)
require.Equal(t, day("2026-12-31"), p.EndsOn)
require.Equal(t, "1000.0000", p.Opening.String())
require.Equal(t, "2243.0000", p.Closing.String())
require.Len(t, p.Lines, 4)
l := p.Lines[0]
require.Equal(t, "-250.0000", l.Amount.String())
require.Equal(t, "SP-2026-0001", l.Reference)
require.Equal(t, "BR123", l.BankReference)
require.Equal(t, "NTRF", l.TransactionCode)
require.Equal(t, "IBG ACME SDN BHD SP-2026-0001 REF 99", l.Description)
require.Equal(t, "", p.Lines[1].Reference, "NONREF is no reference")
require.Equal(t, "DUITNOW 陈大文", p.Lines[1].Description, "bank text is kept verbatim")
require.Equal(t, "5.0000", p.Lines[3].Amount.String(), "RD is a reversal of a debit: money in")
}

Also: TestParseMT940EntryDateAcrossYearEnd (:61:2612310102C10,00NTRFX → value 2026-12-31, booked 2027-01-02; and 2701020102… mirrored back), TestParseMT940FundsCode (:61:260902DR250,00NTRFX reads mark D with funds code R; :61:260902RD250,00NTRFX reads mark RD), TestParseMT940SwiftWrapper (testdata/wrapped.sta: {1:F01…}{2:O940…}{4:\n … -} gives the same Parsed as the bare body), TestParseMT940SkipsZeroLines, and refusals, each after the same file minus the defect parses: TestParseMT940RefusesABrokenLine (:61:26090D250,00NTRF → ParseError{Rule: "mt940_syntax", Line: 5}), TestParseMT940RefusesAnUnbalancedMessage (change :62M: to 2250,49 → Rule: "unbalanced"), TestParseMT940RefusesABrokenChain (change the second :60M: → Rule: "chain"), TestParseMT940RefusesMixedAccounts (second :25: different → Rule: "mixed_accounts"), TestParseMT940RefusesMissingClosing.

csv_test.go:

func TestParseCSVDebitCreditColumnsNewestFirstWithRunningBalance(t *testing.T) {
file := "Bank statement\nDate,Description,Ref,Debit,Credit,Balance\n" +
"03/09/2026,\"DUITNOW, AMINAH\",R2,,\"1,500.50\",\"2,250.50\"\n" +
"02/09/2026,IBG ACME SP-2026-0001,R1,250.00,,750.00\n" +
"\n" +
"Total,,,,,\n"
m := CSVMapping{Delimiter: ",", HeaderRows: 2, FooterRows: 1, DateColumn: 1, DateFormat: "DD/MM/YYYY",
DescriptionColumns: []int{2}, ReferenceColumn: 3, DebitColumn: 4, CreditColumn: 5, BalanceColumn: 6, NewestFirst: true}
p, err := ParseCSV([]byte(file), m)
require.NoError(t, err)
require.Len(t, p.Lines, 2)
require.Equal(t, day("2026-09-02"), p.Lines[0].BookedOn, "oldest first after NewestFirst")
require.Equal(t, "-250.0000", p.Lines[0].Amount.String())
require.Equal(t, "1000.0000", p.Opening.String(), "750 + 250")
require.Equal(t, "2250.5000", p.Closing.String())
}

Also: signed amount_column with (12.50), -12.50, 12.50-, RM 1,000.00; DecimalComma with ; delimiter (1.234,56); a UTF-8 BOM; testdata/latin1.csv containing byte 0xE9 decoding to é; TestParseCSVRefusesAWrongRunningBalance (presence first: the file above parses; then change 750.00 to 751.00 → ParseError{Rule: "running_balance", Line: 3} — the opening is derived from the oldest row, so the first row that does not follow is the 03/09 one, CSV record 3, header rows included); TestParseCSVRefusesBothDebitAndCredit (Rule: "debit_credit"); TestParseCSVRefusesABadDate (Rule: "date"); TestParseCSVRefusesTooManyLines (10 001 rows → Rule: "too_many_lines").

mapping_test.go: the mapping above validates; each of these fails with its field — delimiter: "x", header_rows: 21, date_column: 0, date_format: "YYYYMMDD", amount and debit both set, only debit set, description_columns: [], a column > 100.

  • Step 2: Run — expect failure. go test ./internal/platform/banking/... -count=1 → FAIL: undefined: ParseMT940.

  • Step 3: Implement.

doc.go: package comment — “Package banking is bank accounts, statement import, matching and bank reconciliation (spec 2026-09-27). It is not the §10.6 reconciliation set (internal/platform/accounting/reconcile).” plus the lock order of the Global Constraints.

text.go:

// decodeText turns a bank file into text. Valid UTF-8 is used as is, minus a
// BOM; anything else is read as ISO-8859-1, where every byte is the rune of
// the same number — the one legacy encoding the standard library can decode
// without a table. The bytes themselves are stored unchanged either way.
func decodeText(b []byte) string {
if utf8.Valid(b) {
return strings.TrimPrefix(string(b), "")
}
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}

mt940.go (the non-obvious parts, in full):

var (
tagLine = regexp.MustCompile(`^:(\d{2}[A-Z]?):(.*)$`)
balanceRe = regexp.MustCompile(`^([CD])(\d{6})([A-Z]{3})(\d{1,15},\d*)$`)
line61Re = regexp.MustCompile(`^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d{1,15},\d*)([NFS][A-Z0-9]{3})(.*)$`)
)
type tag struct {
name, value string
line int
}
// unwrap keeps only the bodies of SWIFT block 4 ({4: ... -}) when the file is
// wrapped; a bare MT940 body is returned unchanged.
func unwrap(s string) string {
if !strings.Contains(s, "{4:") {
return s
}
var b strings.Builder
for {
i := strings.Index(s, "{4:")
if i < 0 {
return b.String()
}
s = s[i+3:]
j := strings.Index(s, "-}")
if j < 0 {
j = len(s)
}
b.WriteString(s[:j])
b.WriteString("\n")
s = s[j:]
}
}
// tags splits the body into tags; a line that is not a tag continues the one
// before it (the :61: supplementary line, a multi-line :86:). Line numbers are
// the tag's first line in the file, for the error a person reads.
func tags(text string) []tag {
var out []tag
for i, l := range strings.Split(text, "\n") {
l = strings.TrimRight(l, " \t")
if m := tagLine.FindStringSubmatch(l); m != nil {
out = append(out, tag{name: m[1], value: m[2], line: i + 1})
continue
}
if l == "" || l == "-" || strings.HasPrefix(l, "-}") || len(out) == 0 {
continue
}
out[len(out)-1].value += "\n" + l
}
return out
}
func yymmdd(s string) (time.Time, bool) {
y, _ := strconv.Atoi(s[0:2])
m, _ := strconv.Atoi(s[2:4])
d, _ := strconv.Atoi(s[4:6])
t := time.Date(2000+y, time.Month(m), d, 0, 0, 0, 0, time.UTC)
return t, t.Month() == time.Month(m) && t.Day() == d
}
// entryDate is the MMDD booking date in the year that puts it nearest the
// value date: a line valued 31 Dec and booked 02 Jan is booked next year.
func entryDate(value time.Time, mmdd string) (time.Time, bool) {
m, _ := strconv.Atoi(mmdd[0:2])
d, _ := strconv.Atoi(mmdd[2:4])
var best time.Time
for _, y := range []int{value.Year() - 1, value.Year(), value.Year() + 1} {
c := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC)
if c.Month() != time.Month(m) || c.Day() != d {
return time.Time{}, false
}
if best.IsZero() || absDays(c, value) < absDays(best, value) {
best = c
}
}
return best, true
}
func mtAmount(s string) (money.Amount, error) {
s = strings.Replace(s, ",", ".", 1)
if strings.HasSuffix(s, ".") {
s += "0"
}
return money.Parse(s)
}

ParseMT940 walks tags(unwrap(decodeText(b))) (after \r\n/\r → \n) with a small state machine: 20 starts a message; 25 sets its account; 60F/60M its opening (balanceRe: D negates); 61 appends a pending line (line61Re; value date and optional entry date; C/RD positive, D/RC negative; the remainder of the first line split at the first // into customer and bank reference, NONREF → ""; later lines of the tag value are the supplementary text); 86 directly after a 61 sets that line’s description (its lines joined by one space, runs of spaces collapsed; supplementary text used when there is no 86); 62F/62M closes the message and checks opening + Σ = closing (Rule: "unbalanced"). 28C, 64, 65 and an 86 that follows no 61 are ignored. After the walk: at least one closed message (Rule: "no_statement"); every message’s account and currency equal the first’s (mixed_accounts, mixed_currencies); message n’s opening equals message n−1’s closing (chain); zero-amount lines dropped (B5); more than MaxLines lines → too_many_lines. Every other malformation is mt940_syntax with the tag’s line.

csv.go: encoding/csv reader over decodeText(b) with Comma from the mapping ("\t" → '\t'), LazyQuotes = true, FieldsPerRecord = -1; ReadAll failure → csv_syntax with csv.ParseError.Line. Drop HeaderRows from the front and FooterRows from the back; skip rows whose cells are all blank; a cell beyond the row is blank. Per row (record number n = its index + 1 in the file’s records): date (time.Parse(DateFormats[m.DateFormat], cell) → date), value date likewise when mapped, amount by:

var leadingCurrency = regexp.MustCompile(`^[A-Za-z]{2,3}\s*`)
var plainNumber = regexp.MustCompile(`^\d+(\.\d+)?$`)
// csvAmount reads one amount cell; ok is false for a blank cell.
func csvAmount(s string, decimalComma bool) (a money.Amount, ok bool, err error) {
s = strings.TrimSpace(leadingCurrency.ReplaceAllString(strings.TrimSpace(s), ""))
if s == "" {
return money.Zero(), false, nil
}
neg := false
switch {
case strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"):
neg, s = true, s[1:len(s)-1]
case strings.HasPrefix(s, "-"):
neg, s = true, s[1:]
case strings.HasSuffix(s, "-"):
neg, s = true, s[:len(s)-1]
}
thousands, decimal := ",", "."
if decimalComma {
thousands, decimal = ".", ","
}
s = strings.ReplaceAll(strings.ReplaceAll(s, " ", ""), thousands, "")
s = strings.Replace(s, decimal, ".", 1)
if !plainNumber.MatchString(s) {
return money.Zero(), false, fmt.Errorf("%q is not an amount", s)
}
a, err = money.Parse(s)
if neg {
a = a.Neg()
}
return a, true, err
}

Debit/credit mode: exactly one of the two cells non-blank (debit_credit); the debit is negated (a debit cell holding -x is refused as debit_credit too — the sign is the column’s). Description = the mapped cells joined by one space, trimmed. After the rows: reverse when NewestFirst; with a balance column, opening = balance₁ − amount₁, each row’s balance must equal the previous balance + its amount (running_balance, naming that row’s record number), closing = last balance; drop zero lines; too_many_lines over MaxLines.

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

  • Step 5: Sabotage. In ParseMT940 treat RD as negative; TestParseMT940TwoMessagesChainIntoOneStatement fails at RD is a reversal of a debit; restore. Remove the running-balance comparison; TestParseCSVRefusesAWrongRunningBalance fails at its ParseError assertion; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 1: MT940 and CSV parsers, CSV mapping" -m "Sabotage watched: RD sign, running balance." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 2: bank_account schema and service, test world

Section titled “Task 2: bank_account schema and service, test world”

Files:

  • Create: db/migrations/00350_bank_account.sql, internal/platform/banking/account.go, internal/platform/banking/bankingtest/env.go
  • Modify: internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go (bank_account → amendable)
  • Test: internal/platform/banking/account_test.go, internal/platform/banking/schema_test.go

Interfaces:

  • Consumes: chart.GetAccount, company.Get, audit.Write, recordtext, i18n.Load(ctx, tx, "account", ids, "name").
  • Produces:
type BankAccount struct {
ID, AccountID uuid.UUID
AccountCode string
AccountName string // the record-bearing name; the handler adds the translated label
Currency string
AccountNumber string
MatchWindowDays int
MatchOnAmountDate bool
CSVMapping *CSVMapping
IsActive bool
Revision int
CreatedBy uuid.UUID
CreatedAt, UpdatedAt time.Time
}
type BankAccountInput struct {
AccountID uuid.UUID // create only; ignored by Update
AccountNumber string
MatchWindowDays int
MatchOnAmountDate bool
CSVMapping *CSVMapping
IsActive bool // update only; a new account is active
}
var (
ErrNotFound = errors.New("banking: not found")
ErrAlreadyLinked = errors.New("banking: that GL account already has a bank account")
ErrInactive = errors.New("banking: the bank account is inactive")
)
type RevisionMismatchError struct{ Current int }
func CreateBankAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in BankAccountInput) (BankAccount, error)
func GetBankAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (BankAccount, error)
func GetByGLAccount(ctx context.Context, tx pgx.Tx, glAccountID uuid.UUID) (BankAccount, error) // for payment batches
func ListBankAccounts(ctx context.Context, tx pgx.Tx) ([]BankAccount, error) // by account code
func UpdateBankAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, revision int, in BankAccountInput) (BankAccount, error)
func lockBankAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (BankAccount, error) // FOR UPDATE; every write starts here
// bankingtest
type Env struct{ *expensestest.Env }
func New(t *testing.T) *Env
func (e *Env) As(member uuid.UUID) context.Context
func (e *Env) GLBank(t *testing.T, code string, currency *string) uuid.UUID // a new postable bank-control asset account under 1500
func (e *Env) BankAccount(t *testing.T, glCode, number string) banking.BankAccount // GLBank + CreateBankAccount by Staff, window 3, rule 2 on
func (e *Env) Book(t *testing.T, gl uuid.UUID, date, amount, memo string) uuid.UUID
// posts a manual entry: gl Dr (amount > 0) or Cr, against 6190 Sundry; returns the bank-side journal_line id
  • Step 1: Failing tests.

schema_test.go (package banking_test): TestBankAccountGuardRefusesANonBankGLAccount — presence first: as the app role, INSERT INTO bank_account … account_id = <GLBank> succeeds; then with account_id = account 6180 (expense) → 23514 constraint bank_account_gl_is_bank; with a GL bank account restricted to USD and currency = 'MYR' → same constraint. TestBankAccountIdentityIsFixed — UPDATE bank_account SET account_number = '9999' succeeds; SET currency = 'USD' → bank_account_identity_fixed.

account_test.go: TestCreateBankAccountDerivesItsCurrency (null-currency GL → MYR; USD GL → USD), TestCreateBankAccountNormalisesTheNumber ("5140-1234 5678" → "514012345678"; "12a" → FieldError{Field: "account_number", Rule: "digits"}), TestCreateBankAccountRefusesAnUnlinkableGLAccount (presence first: a bank GL links; then 6180 → FieldError{Field: "account_id", Rule: "bank_account"}; the same GL again → ErrAlreadyLinked), TestCreateBankAccountValidatesTheMapping (a bad mapping → FieldError csv_mapping.date_format), TestUpdateBankAccountNeedsTheCurrentRevision (revision 1 updates to 2; revision 1 again → *RevisionMismatchError{Current: 2}), TestMatchWindowRange (0 and 31 accepted, 32 → match_window_days), TestBankAccountsAreAudited (one audit_log row per create/update, entity_kind = 'bank_account').

  • Step 2: Run — expect failure. go test ./internal/platform/banking/... -count=1 → FAIL (undefined: CreateBankAccount; the schema tests fail on the missing table).

  • Step 3: Implement.

db/migrations/00350_bank_account.sql:

-- +goose Up
-- BANK ACCOUNTS (bank reconciliation spec D1): a role on one GL account of
-- control kind 'bank' (00050 allows many). No name: the GL account's name and
-- its translation rows are the name. currency is the statement currency,
-- derived from the GL account (or the base currency when it takes any).
CREATE TABLE bank_account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
account_id UUID NOT NULL,
currency TEXT NOT NULL CONSTRAINT bank_account_currency_code CHECK (currency ~ '^[A-Z]{3}$'),
account_number TEXT NOT NULL CONSTRAINT bank_account_number_digits CHECK (account_number ~ '^[0-9]{4,34}$'),
match_window_days INT NOT NULL DEFAULT 3 CONSTRAINT bank_account_match_window_range CHECK (match_window_days BETWEEN 0 AND 31),
match_on_amount_date BOOLEAN NOT NULL DEFAULT true,
csv_mapping JSONB CONSTRAINT bank_account_csv_mapping_object CHECK (csv_mapping IS NULL OR jsonb_typeof(csv_mapping) = 'object'),
is_active BOOLEAN NOT NULL DEFAULT true,
revision INT NOT NULL DEFAULT 1 CONSTRAINT bank_account_revision_positive CHECK (revision >= 1),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT bank_account_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_account_gl_once UNIQUE (org_id, account_id),
CONSTRAINT bank_account_gl_fk FOREIGN KEY (org_id, account_id) REFERENCES account (org_id, id),
CONSTRAINT bank_account_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id)
);
ALTER TABLE bank_account ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_account FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON bank_account
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON bank_account FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION bank_account_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE v_kind TEXT; v_postable BOOLEAN; v_active BOOLEAN; v_currency TEXT; v_base TEXT;
BEGIN
IF TG_OP = 'UPDATE' THEN
IF NEW.account_id IS DISTINCT FROM OLD.account_id OR NEW.currency IS DISTINCT FROM OLD.currency THEN
RAISE EXCEPTION 'bank account %: its GL account and currency are fixed once created', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_account_identity_fixed';
END IF;
RETURN NEW;
END IF;
SELECT a.control_kind, a.is_postable, a.is_active, a.currency INTO v_kind, v_postable, v_active, v_currency
FROM public.account a WHERE a.org_id = NEW.org_id AND a.id = NEW.account_id;
SELECT cp.base_currency INTO v_base FROM public.company_profile cp WHERE cp.org_id = NEW.org_id;
IF v_kind IS DISTINCT FROM 'bank' OR v_postable IS NOT TRUE OR v_active IS NOT TRUE
OR NEW.currency IS DISTINCT FROM COALESCE(v_currency, v_base) THEN
RAISE EXCEPTION 'bank account %: the GL account must be an active postable bank account in its currency', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_account_gl_is_bank';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_account_guard BEFORE INSERT OR UPDATE ON bank_account
FOR EACH ROW EXECUTE FUNCTION bank_account_guard();
-- +goose Down
DROP TABLE bank_account;
DROP FUNCTION bank_account_guard();

account.go: plain SQL in the payables style (const bankAccountColumns, scanBankAccount joining account for code/name with a.org_id = $1 on both sides). CreateBankAccount: chart.GetAccount (not found → the account_id field error), check active/postable/bank (field error), company.Get for the base, normalise and check the number, in.CSVMapping.Validate() when non-nil, window 0–31, insert (23505 on bank_account_gl_once → ErrAlreadyLinked), audit create. UpdateBankAccount: lockBankAccount, revision check, the same validations, revision = revision + 1, audit update with before/after. lockBankAccount is … WHERE b.org_id = $1 AND b.id = $2 FOR UPDATE OF b.

bankingtest/env.go: GLBank creates the account through chart.CreateAccount (type asset, parent 1500, ControlKind: "bank", the given currency); Book calls ledger.Post with SemanticManual, JournalType: chart.JournalBank, PostedBy: &e.Owner, description "test " + memo, and returns the id of the line on gl.

  • Step 4: Register the range and retention. migrationranges_test.go gains {350, 369, "Phase 2 bank reconciliation"}; retention_test.go’s amendable gains "bank_account" under a // Bank reconciliation (00350–00353) comment.

  • Step 5: Run passing. go test ./internal/platform/banking/... ./internal/kernel/db/... -count=1 → PASS (including TestEveryMigrationIsNumberedInItsOwnersRange, the RLS coverage, tenant-FK, constraint-name, search-path and retention drift tests).

  • Step 6: Sabotage. Comment out OR NEW.currency IS DISTINCT FROM COALESCE(v_currency, v_base); TestBankAccountGuardRefusesANonBankGLAccount fails at the USD case; restore. Delete "bank_account" from amendable; the retention test fails naming bank_account; restore.

  • Step 7: Commit. git add -A && git commit -m "Bank rec Task 2: bank_account, service, test world" -m "Sabotage watched: bank_account_gl_is_bank currency clause; retention list." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 3: Statements — schema, import, void

Section titled “Task 3: Statements — schema, import, void”

Files:

  • Create: db/migrations/00351_bank_statement.sql, internal/platform/banking/statement.go, internal/platform/banking/bankingtest/statements.go
  • Modify: internal/kernel/db/retention_test.go (bank_statement → amendable, bank_statement_line → appendOnly)
  • Test: internal/platform/banking/statement_test.go, internal/platform/banking/statement_schema_test.go

Interfaces:

  • Consumes: Task 1 parsers, Task 2 lockBankAccount, audit.Write, recordtext.Check.
  • Produces:
const MaxFileBytes = 5 << 20
type Statement struct {
ID, BankAccountID uuid.UUID
Format string // "mt940" | "csv"
StartsOn, EndsOn time.Time
Currency string
Opening, Closing money.Amount
LineCount int
FileName, FileSHA256 string
ImportedBy uuid.UUID
ImportedAt time.Time
VoidedAt *time.Time
VoidedBy *uuid.UUID
VoidReason *string
}
type StatementLine struct {
ID, StatementID uuid.UUID
LineNo int
BookedOn time.Time
ValueOn *time.Time
Amount money.Amount
Description, Reference, BankReference, TransactionCode string
}
type ImportInput struct {
BankAccountID uuid.UUID
Format, FileName string
File []byte
StartsOn, EndsOn *time.Time // CSV: required
Opening, Closing *money.Amount // CSV: see spec D5
By uuid.UUID
}
type ImportResult struct {
Statement Statement
AutoMatched int
}
type DuplicateFileError struct{ StatementID uuid.UUID } // 409 duplicate_file
type OpeningMismatchError struct{ Expected money.Amount } // 422 field=opening_balance rule=chain
var (
ErrNotAfterPrevious = errors.New("banking: a statement must end after the account's latest statement") // 409
ErrNoCSVMapping = errors.New("banking: this bank account has no CSV mapping") // 422
ErrVoidNotAllowed = errors.New("banking: only the latest statement, unmatched and unlocked, can be voided") // 409
ErrVoided = errors.New("banking: the statement is voided") // 409
)
func Import(ctx context.Context, tx pgx.Tx, c clock.Clock, in ImportInput) (ImportResult, error)
func VoidStatement(ctx context.Context, tx pgx.Tx, c clock.Clock, id, by uuid.UUID, reason string) (Statement, error)
func GetStatement(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Statement, []StatementLine, error)
func ListStatements(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID) ([]Statement, error) // ends_on DESC
func StatementFile(ctx context.Context, tx pgx.Tx, id uuid.UUID) (name string, body []byte, err error)
func latestStatement(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID) (*Statement, error) // non-voided, max ends_on
// bankingtest
func MT940(account, currency string, opening string, openingOn string, lines []MTLine, closingOn string) []byte
type MTLine struct{ Date, Amount, Ref, Details string } // Amount signed, "−" → D
func (e *Env) Import(t *testing.T, b banking.BankAccount, file []byte) banking.ImportResult
  • Step 1: Failing tests.

statement_test.go:

  • TestImportStoresTheFileAndItsLines — an MT940 with two lines imports; LineCount = 2; StatementFile returns the bytes; file_sha256 = sha256.Sum256 hex.
  • TestImportRefusesTheSameFileTwice — presence first (the first import succeeds and a different file for the next month succeeds); the first bytes again → *DuplicateFileError{StatementID: first}; and one CSV file imported into bank account A, then the same bytes into bank account B (its first statement, so no chain applies) → *DuplicateFileError too (uniqueness is per organization).
  • TestImportChainsBalances — presence: statement 2 with opening = statement 1’s closing imports; a statement 3 whose opening differs → *OpeningMismatchError{Expected: <2's closing>}; one whose ends_on equals statement 2’s → ErrNotAfterPrevious.
  • TestImportChecksTheMT940Account — :25: MBBEMYKL/514012345678 into bank account number 514012345678 imports; into 999912345678 → FieldError{Field: "file", Rule: "account_mismatch"}; currency USD into a MYR account → currency_mismatch.
  • TestImportCSV — with the Task 1 mapping and a balance column: opening/closing derived, no balances needed in the input; without a balance column: the first statement needs Opening and Closing (FieldError opening_balance/closing_balance required), a later one defaults Opening to the previous closing; a Closing that does not equal opening + Σ → FieldError{Field: "closing_balance", Rule: "unbalanced"}; StartsOn/EndsOn required, StartsOn > EndsOn refused; no mapping on the account → ErrNoCSVMapping.
  • TestImportRefusesAnOversizedFile (MaxFileBytes + 1 → FieldError{Field: "file", Rule: "size"}; an empty file → size).
  • TestVoidThenReimport — void the latest (reason "Pemetaan lajur salah") succeeds; its bytes import again; voiding a statement that is not the latest → ErrVoidNotAllowed; a reason in Chinese → FieldError{Field: "reason", Rule: "record_language"}; voiding twice → ErrVoided.
  • TestImportIsAudited — one bank_statement import audit row whose after has no file_bytes key.

statement_schema_test.go (DB backstops, as the app role, bypassing Go):

  • TestStatementLinesCannotBeAddedLater — presence: inserting a statement and its lines in one transaction commits; a later transaction inserting a line into it → bank_statement_line_statement_open.

  • TestStatementMustBalanceAtCommit — a statement whose lines do not sum to closing − opening fails at COMMIT with bank_statement_balanced; a line_count that disagrees too.

  • TestStatementIsImmutable — setting the three void columns on the latest statement succeeds; UPDATE … SET closing_balance = … → bank_statement_immutable.

  • TestStatementChainInTheDatabase — an insert whose opening is not the previous closing → bank_statement_chain.

  • TestStatementHashMatchesItsBytes — a wrong file_sha256 → bank_statement_file_sha256_matches.

  • Step 2: Run — expect failure. go test ./internal/platform/banking/... -count=1 → FAIL.

  • Step 3: Implement.

db/migrations/00351_bank_statement.sql (constraint names are load-bearing; the rest is the Task 2 shape):

-- +goose Up
-- BANK STATEMENTS (spec D3): one import, immutable, the file kept with its
-- hash, chained to the account's previous statement. Voiding is the only
-- change a statement can undergo, once; its lines never change.
CREATE TABLE bank_statement (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
bank_account_id UUID NOT NULL,
format TEXT NOT NULL CONSTRAINT bank_statement_format_known CHECK (format IN ('mt940','csv')),
starts_on DATE NOT NULL,
ends_on DATE NOT NULL,
currency TEXT NOT NULL CONSTRAINT bank_statement_currency_code CHECK (currency ~ '^[A-Z]{3}$'),
opening_balance NUMERIC(19,4) NOT NULL,
closing_balance NUMERIC(19,4) NOT NULL,
line_count INT NOT NULL CONSTRAINT bank_statement_line_count_range CHECK (line_count BETWEEN 0 AND 10000),
file_name TEXT NOT NULL CONSTRAINT bank_statement_file_name_present CHECK (btrim(file_name) <> '' AND length(file_name) <= 255),
file_sha256 TEXT NOT NULL,
file_bytes BYTEA NOT NULL CONSTRAINT bank_statement_file_size CHECK (octet_length(file_bytes) BETWEEN 1 AND 5242880),
csv_mapping JSONB,
imported_by UUID NOT NULL,
imported_at TIMESTAMPTZ NOT NULL,
imported_txid xid8 NOT NULL DEFAULT pg_current_xact_id(),
voided_at TIMESTAMPTZ,
voided_by UUID,
void_reason TEXT,
CONSTRAINT bank_statement_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_statement_account_id UNIQUE (org_id, id, bank_account_id),
CONSTRAINT bank_statement_period_order CHECK (starts_on <= ends_on),
CONSTRAINT bank_statement_file_sha256_matches CHECK (file_sha256 = encode(sha256(file_bytes), 'hex')),
CONSTRAINT bank_statement_mapping_iff_csv CHECK ((format = 'csv') = (csv_mapping IS NOT NULL)),
CONSTRAINT bank_statement_void_complete CHECK (num_nonnulls(voided_at, voided_by, void_reason) IN (0, 3)),
CONSTRAINT bank_statement_void_reason_present CHECK (void_reason IS NULL OR (btrim(void_reason) <> '' AND length(void_reason) <= 500)),
CONSTRAINT bank_statement_void_reason_language CHECK (void_reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT bank_statement_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES bank_account (org_id, id),
CONSTRAINT bank_statement_imported_by_fk FOREIGN KEY (org_id, imported_by) REFERENCES organization_member (org_id, id),
CONSTRAINT bank_statement_voided_by_fk FOREIGN KEY (org_id, voided_by) REFERENCES organization_member (org_id, id)
);
CREATE UNIQUE INDEX bank_statement_file_once ON bank_statement (org_id, file_sha256) WHERE voided_at IS NULL;
CREATE UNIQUE INDEX bank_statement_ends_once ON bank_statement (org_id, bank_account_id, ends_on) WHERE voided_at IS NULL;
-- Statement text is the bank's, stored verbatim: deliberately no
-- record-language CHECK here (spec D3).
CREATE TABLE bank_statement_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
statement_id UUID NOT NULL,
bank_account_id UUID NOT NULL,
line_no INT NOT NULL CONSTRAINT bank_statement_line_no_positive CHECK (line_no >= 1),
booked_on DATE NOT NULL,
value_on DATE,
amount NUMERIC(19,4) NOT NULL CONSTRAINT bank_statement_line_amount_nonzero CHECK (amount <> 0),
description TEXT NOT NULL DEFAULT '' CONSTRAINT bank_statement_line_description_length CHECK (length(description) <= 2000),
reference TEXT NOT NULL DEFAULT '' CONSTRAINT bank_statement_line_reference_length CHECK (length(reference) <= 140),
bank_reference TEXT NOT NULL DEFAULT '' CONSTRAINT bank_statement_line_bank_reference_length CHECK (length(bank_reference) <= 140),
transaction_code TEXT NOT NULL DEFAULT '' CONSTRAINT bank_statement_line_transaction_code_length CHECK (length(transaction_code) <= 16),
CONSTRAINT bank_statement_line_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_statement_line_account_id UNIQUE (org_id, id, bank_account_id),
CONSTRAINT bank_statement_line_no_once UNIQUE (org_id, statement_id, line_no),
CONSTRAINT bank_statement_line_statement_fk FOREIGN KEY (org_id, statement_id, bank_account_id)
REFERENCES bank_statement (org_id, id, bank_account_id)
);
ALTER TABLE bank_statement ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_statement FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON bank_statement
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
ALTER TABLE bank_statement_line ENABLE ROW LEVEL SECURITY;
ALTER TABLE bank_statement_line FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON bank_statement_line
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON bank_statement FROM erp_app;
REVOKE UPDATE, DELETE ON bank_statement_line FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION bank_statement_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE v_currency TEXT; v_prev public.bank_statement%ROWTYPE;
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.imported_txid := pg_current_xact_id();
SELECT a.currency INTO v_currency FROM public.bank_account a
WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE;
IF NEW.currency IS DISTINCT FROM v_currency THEN
RAISE EXCEPTION 'statement %: currency % is not the bank account''s %', NEW.id, NEW.currency, v_currency
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_currency';
END IF;
SELECT * INTO v_prev FROM public.bank_statement s
WHERE s.org_id = NEW.org_id AND s.bank_account_id = NEW.bank_account_id AND s.voided_at IS NULL
ORDER BY s.ends_on DESC LIMIT 1;
IF FOUND AND (NEW.ends_on <= v_prev.ends_on OR NEW.opening_balance <> v_prev.closing_balance) THEN
RAISE EXCEPTION 'statement %: must end after % and open at %', NEW.id, v_prev.ends_on, v_prev.closing_balance
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_chain';
END IF;
RETURN NEW;
END IF;
IF OLD.voided_at IS NOT NULL OR NEW.voided_at IS NULL
OR ROW(NEW.org_id, NEW.bank_account_id, NEW.format, NEW.starts_on, NEW.ends_on, NEW.currency,
NEW.opening_balance, NEW.closing_balance, NEW.line_count, NEW.file_name, NEW.file_sha256,
NEW.file_bytes, NEW.csv_mapping, NEW.imported_by, NEW.imported_at, NEW.imported_txid)
IS DISTINCT FROM
ROW(OLD.org_id, OLD.bank_account_id, OLD.format, OLD.starts_on, OLD.ends_on, OLD.currency,
OLD.opening_balance, OLD.closing_balance, OLD.line_count, OLD.file_name, OLD.file_sha256,
OLD.file_bytes, OLD.csv_mapping, OLD.imported_by, OLD.imported_at, OLD.imported_txid) THEN
RAISE EXCEPTION 'statement %: only an unvoided statement can change, and only by being voided', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_immutable';
END IF;
PERFORM 1 FROM public.bank_account a WHERE a.org_id = OLD.org_id AND a.id = OLD.bank_account_id FOR UPDATE;
IF EXISTS (SELECT 1 FROM public.bank_statement s
WHERE s.org_id = OLD.org_id AND s.bank_account_id = OLD.bank_account_id
AND s.voided_at IS NULL AND s.ends_on > OLD.ends_on) THEN
RAISE EXCEPTION 'statement %: only the latest statement can be voided', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_void_allowed';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_statement_guard BEFORE INSERT OR UPDATE ON bank_statement
FOR EACH ROW EXECUTE FUNCTION bank_statement_guard();
-- +goose StatementBegin
CREATE FUNCTION bank_statement_line_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF (SELECT s.imported_txid FROM public.bank_statement s WHERE s.org_id = NEW.org_id AND s.id = NEW.statement_id)
IS DISTINCT FROM pg_current_xact_id() THEN
RAISE EXCEPTION 'statement %: lines are written only by the import that wrote it', NEW.statement_id
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_line_statement_open';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_statement_line_guard BEFORE INSERT ON bank_statement_line
FOR EACH ROW EXECUTE FUNCTION bank_statement_line_guard();
-- The balance and count checks, at COMMIT, from the statement and again from
-- every line (so a check fired early cannot be outrun — the ledger's rule).
-- +goose StatementBegin
CREATE FUNCTION bank_statement_check(p_org UUID, p_statement UUID) RETURNS void
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE v public.bank_statement%ROWTYPE; v_n INT; v_sum NUMERIC;
BEGIN
IF p_org IS DISTINCT FROM current_setting('app.current_org', true)::uuid THEN
RAISE EXCEPTION 'statement %: checked under another organization', p_statement
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_balanced';
END IF;
SELECT * INTO v FROM public.bank_statement s WHERE s.org_id = p_org AND s.id = p_statement;
SELECT count(*), COALESCE(sum(l.amount), 0) INTO v_n, v_sum
FROM public.bank_statement_line l WHERE l.org_id = p_org AND l.statement_id = p_statement;
IF v_n <> v.line_count OR v.opening_balance + v_sum <> v.closing_balance THEN
RAISE EXCEPTION 'statement %: % lines summing % do not take % to %', p_statement, v_n, v_sum,
v.opening_balance, v.closing_balance
USING ERRCODE = '23514', CONSTRAINT = 'bank_statement_balanced';
END IF;
END $$;
CREATE FUNCTION bank_statement_balanced() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN PERFORM public.bank_statement_check(NEW.org_id, NEW.id); RETURN NULL; END $$;
CREATE FUNCTION bank_statement_line_balanced() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN PERFORM public.bank_statement_check(NEW.org_id, NEW.statement_id); RETURN NULL; END $$;
-- +goose StatementEnd
CREATE CONSTRAINT TRIGGER bank_statement_balanced AFTER INSERT ON bank_statement
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION bank_statement_balanced();
CREATE CONSTRAINT TRIGGER bank_statement_line_balanced AFTER INSERT ON bank_statement_line
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION bank_statement_line_balanced();
-- +goose Down
DROP TABLE bank_statement_line;
DROP TABLE bank_statement;
DROP FUNCTION bank_statement_line_balanced();
DROP FUNCTION bank_statement_balanced();
DROP FUNCTION bank_statement_check(UUID, UUID);
DROP FUNCTION bank_statement_line_guard();
DROP FUNCTION bank_statement_guard();

statement.go — Import, in this order (every refusal before any write):

  1. acct, err := lockBankAccount(ctx, tx, in.BankAccountID); !acct.IsActive → ErrInactive.
  2. 1 ≤ len(in.File) ≤ MaxFileBytes (file/size); file name trimmed, 1–255 characters (file_name/required).
  3. sum := sha256.Sum256(in.File); an existing non-voided statement with that hash → *DuplicateFileError.
  4. Parse by in.Format (format/known otherwise). MT940: digits(p.Account) must contain acct.AccountNumber (account_mismatch); p.Currency == acct.Currency (currency_mismatch); starts/ends/opening/closing from p. CSV: acct.CSVMapping == nil → ErrNoCSVMapping; StartsOn/EndsOn required; balances per spec D5 (a mapped balance column wins; an input balance that disagrees with it → opening_balance/closing_balance rule=balance_column).
  5. prev := latestStatement(…): when present, ends ≤ prev.EndsOn → ErrNotAfterPrevious; opening ≠ prev.Closing → *OpeningMismatchError{Expected: prev.Closing}; for CSV with Opening == nil, opening = prev.Closing. When absent and no opening → opening_balance/required.
  6. opening + Σ lines ≠ closing → closing_balance/unbalanced (MT940’s own check already ran; this is the CSV one).
  7. Insert the statement (id = uuid.New(), csv_mapping = the account’s mapping for CSV) and its lines in one INSERT … SELECT … FROM unnest($3::int[], $4::date[], $5::date[], $6::numeric[], $7::text[], $8::text[], $9::text[], $10::text[]) (B4; amounts passed as []string from Amount.String()).
  8. ids, err := autoMatch(ctx, tx, c, acct, in.By) (Task 5 fills it in; this task adds func autoMatch(...) ([]uuid.UUID, error) { return nil, nil } in automatch.go).
  9. audit.Write (bank_statement, import, After: the Statement plus "auto_matched": ids).

VoidStatement: read the statement to learn its account, lockBankAccount, re-read; voided → ErrVoided; recordtext.Check("reason", …) and 1–500 characters; not the latest → ErrVoidNotAllowed (Task 4 adds the match and lock conditions here and in SQL); UPDATE … SET voided_at, voided_by, void_reason; audit void.

MapError(err error) error (in errors.go): unwrap *pgconn.PgError code 23514/23505 by ConstraintName onto this package’s errors — bank_statement_file_once → *DuplicateFileError (StatementID unknown: uuid.Nil), bank_statement_chain/bank_statement_ends_once → ErrNotAfterPrevious, bank_statement_void_allowed → ErrVoidNotAllowed, bank_statement_balanced → FieldError{"file","unbalanced",…}; anything else → ledger.MapError(err). Task 4 adds its names.

bankingtest/statements.go: MT940 renders one message (:20:TEST, :25: = "MBBEMYKL/" + account, :60F:, one :61:+:86: per line using the line date as value and entry date, :62F:), computing the closing from the opening and lines; (*Env).Import imports it as Staff.

  • Step 4: Retention. amendable += "bank_statement"; appendOnly += "bank_statement_line".

  • Step 5: Run passing. go test ./internal/platform/banking/... ./internal/kernel/db/... -count=1 → PASS.

  • Step 6: Sabotage. Drop OR NEW.opening_balance <> v_prev.closing_balance from the guard; TestStatementChainInTheDatabase fails; restore. Make bank_statement_line_guard RETURN NEW unconditionally; TestStatementLinesCannotBeAddedLater fails; restore. Remove step 3 in Go; the cross-account CSV case of TestImportRefusesTheSameFileTwice still gets *DuplicateFileError, now from bank_statement_file_once through MapError (the index is the backstop; the same-account case is answered by the chain check first, ErrNotAfterPrevious — record that too); then also comment out that CREATE UNIQUE INDEX and watch the cross-account case go red; restore both.

  • Step 7: Commit. git add -A && git commit -m "Bank rec Task 3: statements, import, void" -m "Sabotage watched: bank_statement_chain, bank_statement_line_statement_open, bank_statement_file_once." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 4: Match groups, unmatch, opening clearance — schema and service

Section titled “Task 4: Match groups, unmatch, opening clearance — schema and service”

Files:

  • Create: db/migrations/00352_bank_match_reconciliation.sql, internal/platform/banking/match.go, internal/platform/banking/bankingtest/match.go
  • Modify: internal/platform/banking/statement.go (void conditions), internal/platform/banking/errors.go (MapError names), internal/kernel/db/retention_test.go
  • Test: internal/platform/banking/match_test.go, internal/platform/banking/match_schema_test.go

Interfaces:

  • Consumes: Tasks 2–3; company.Get.
  • Produces:
const (
KindAuto = "auto"; KindManual = "manual"; KindJournal = "journal"; KindOpening = "opening"
RuleReference = "reference"; RuleAmountDate = "amount_date"
)
type BookLine struct {
JournalLineID, EntryID uuid.UUID
EntryNo string
EntryDate time.Time
Description, Memo string
SourceKind *string
Amount money.Amount // spec D2
MatchID *uuid.UUID // active match, if any
}
type Match struct {
ID, BankAccountID uuid.UUID
Kind string
Rule *string
ClearedOn time.Time
StatementLineIDs, JournalLineIDs []uuid.UUID
MatchedBy uuid.UUID
MatchedAt time.Time
UnmatchedAt *time.Time
UnmatchedBy *uuid.UUID
UnmatchReason *string
}
type MatchInput struct {
BankAccountID uuid.UUID
StatementLineIDs, JournalLineIDs []uuid.UUID
By uuid.UUID
}
type UnbalancedError struct{ Statement, Book money.Amount } // 422 rule=unbalanced
var (
ErrLineTaken = errors.New("banking: a line is already in an active match") // 409 line_taken
ErrLineForeign = errors.New("banking: a line is not on this bank account") // 422
ErrLocked = errors.New("banking: that falls within a locked reconciliation") // 409 reconciliation_locked
ErrUnmatched = errors.New("banking: the match is already unmatched") // 409
ErrNotOpening = errors.New("banking: opening clearance takes book lines dated before the first statement") // 422
)
type BookLineFilter struct{ From, To time.Time; UnmatchedOnly bool }
func BookLines(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID, f BookLineFilter) ([]BookLine, bool, error) // truncated at 2000 (B11)
func MatchLines(ctx context.Context, tx pgx.Tx, c clock.Clock, in MatchInput) (Match, error) // kind manual; audit "match"
func OpeningClearance(ctx context.Context, tx pgx.Tx, c clock.Clock, bankAccountID uuid.UUID, journalLineIDs []uuid.UUID, by uuid.UUID) (Match, error) // audit "opening_clearance"
func Unmatch(ctx context.Context, tx pgx.Tx, c clock.Clock, matchID, by uuid.UUID, reason string) (Match, error) // audit "unmatch"
func GetMatch(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Match, error)
func LineHistory(ctx context.Context, tx pgx.Tx, statementLineIDs []uuid.UUID) (map[uuid.UUID][]Match, error) // for B12
// unexported, shared by Tasks 5–7; the caller holds the bank-account lock
func insertMatch(ctx context.Context, tx pgx.Tx, c clock.Clock, acct BankAccount, kind string, rule *string, stmt, book []uuid.UUID, by uuid.UUID) (Match, error)
func lockedThrough(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID) (*time.Time, error) // max(as_of) of bank_reconciliation
func bookAmountExpr(baseIsBank bool) string // "jl.base_debit - jl.base_credit" | "jl.debit - jl.credit"
  • Step 1: Failing tests.

match_test.go:

  • TestMatchOneToOne — book +500 and a statement line +500 match (ClearedOn = the statement’s ends_on when that is later than the entry date); both lines then show MatchID.
  • TestMatchManyToOne — two book lines 300 + 200 against one statement line 500.
  • TestMatchRefusesAnUnbalancedGroup — presence: 500 ↔ 500 matches; 500 ↔ 499 → *UnbalancedError{Statement: 500, Book: 499}.
  • TestContraMatchOfAnEntryAndItsReversal — a book line and the line of its ledger.Reverse (Σ = 0) match with no statement line.
  • TestALineIsInOneActiveMatch — presence: line L matches; a second group naming L → ErrLineTaken; after Unmatch(reason "Salah padanan") it matches again; LineHistory shows both groups, the first with UnmatchedAt.
  • TestMatchRefusesAForeignLine — a book line on another GL account → ErrLineForeign; a statement line of another bank account → ErrLineForeign; a line of a voided statement → ErrVoided.
  • TestOpeningClearance — presence: with the first statement starting 2026-09-01, a book line dated 2026-08-15 clears (kind opening, one item); one dated 2026-09-01 → ErrNotOpening; with no statement yet → ErrNotOpening.
  • TestUnmatchNeedsAReason (blank → reason/required; Chinese → record_language); TestUnmatchTwice → ErrUnmatched.
  • TestVoidRefusedWhileMatched — presence: an unmatched latest statement voids (in a fresh env); a matched one → ErrVoidNotAllowed; after unmatching, voids.
  • TestBookAmountUsesBaseForABaseCurrencyBank — a USD entry posted to a null-currency GL bank (base MYR) at a frozen base appears with its base amount.

match_schema_test.go (app role, bypassing Go; each after its accepted twin):

  • bank_match_item_line_free (two active groups naming one statement line), bank_match_item_on_account, bank_match_item_match_open (an item added to a match from an earlier transaction), bank_match_balanced at COMMIT, bank_match_valid (a wrong cleared_on), bank_match_opening (opening with a statement line), bank_statement_void_allowed (voiding a statement with a matched line, directly in SQL).

  • Step 2: Run — expect failure.

  • Step 3: Implement.

db/migrations/00352_bank_match_reconciliation.sql:

-- +goose Up
-- MATCHES (spec D6): a group of statement lines and book lines whose amounts
-- agree. Insert-only; unmatching inserts a bank_unmatch row. cleared_on is the
-- latest item date (a statement line's date is its statement's ends_on).
CREATE TABLE bank_match (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
bank_account_id UUID NOT NULL,
kind TEXT NOT NULL CONSTRAINT bank_match_kind_known CHECK (kind IN ('auto','manual','journal','opening')),
rule TEXT CONSTRAINT bank_match_rule_known CHECK (rule IN ('reference','amount_date')),
cleared_on DATE NOT NULL,
matched_by UUID NOT NULL,
matched_at TIMESTAMPTZ NOT NULL,
created_txid xid8 NOT NULL DEFAULT pg_current_xact_id(),
CONSTRAINT bank_match_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_match_account_id UNIQUE (org_id, id, bank_account_id),
CONSTRAINT bank_match_rule_iff_auto CHECK ((kind = 'auto') = (rule IS NOT NULL)),
CONSTRAINT bank_match_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES bank_account (org_id, id),
CONSTRAINT bank_match_matched_by_fk FOREIGN KEY (org_id, matched_by) REFERENCES organization_member (org_id, id)
);
CREATE TABLE bank_match_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
match_id UUID NOT NULL,
bank_account_id UUID NOT NULL,
statement_line_id UUID,
journal_line_id UUID,
CONSTRAINT bank_match_item_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_match_item_one_side CHECK (num_nonnulls(statement_line_id, journal_line_id) = 1),
CONSTRAINT bank_match_item_match_fk FOREIGN KEY (org_id, match_id, bank_account_id)
REFERENCES bank_match (org_id, id, bank_account_id),
CONSTRAINT bank_match_item_statement_line_fk FOREIGN KEY (org_id, statement_line_id, bank_account_id)
REFERENCES bank_statement_line (org_id, id, bank_account_id),
CONSTRAINT bank_match_item_journal_line_fk FOREIGN KEY (org_id, journal_line_id) REFERENCES journal_line (org_id, id)
);
CREATE UNIQUE INDEX bank_match_item_statement_once ON bank_match_item (org_id, match_id, statement_line_id) WHERE statement_line_id IS NOT NULL;
CREATE UNIQUE INDEX bank_match_item_journal_once ON bank_match_item (org_id, match_id, journal_line_id) WHERE journal_line_id IS NOT NULL;
CREATE INDEX bank_match_item_by_statement_line ON bank_match_item (org_id, statement_line_id) WHERE statement_line_id IS NOT NULL;
CREATE INDEX bank_match_item_by_journal_line ON bank_match_item (org_id, journal_line_id) WHERE journal_line_id IS NOT NULL;
CREATE TABLE bank_unmatch (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
match_id UUID NOT NULL,
bank_account_id UUID NOT NULL,
reason TEXT NOT NULL CONSTRAINT bank_unmatch_reason_present CHECK (btrim(reason) <> '' AND length(reason) <= 500),
unmatched_by UUID NOT NULL,
unmatched_at TIMESTAMPTZ NOT NULL,
CONSTRAINT bank_unmatch_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_unmatch_once UNIQUE (org_id, match_id),
CONSTRAINT bank_unmatch_reason_language CHECK (reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
CONSTRAINT bank_unmatch_match_fk FOREIGN KEY (org_id, match_id, bank_account_id) REFERENCES bank_match (org_id, id, bank_account_id),
CONSTRAINT bank_unmatch_unmatched_by_fk FOREIGN KEY (org_id, unmatched_by) REFERENCES organization_member (org_id, id)
);
-- LOCKED RECONCILIATIONS (spec D12): only a locked one is stored, so both
-- tables are insert-only. The totals must add up and the difference be zero.
CREATE TABLE bank_reconciliation (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
bank_account_id UUID NOT NULL,
statement_id UUID NOT NULL,
as_of DATE NOT NULL,
statement_balance NUMERIC(19,4) NOT NULL,
book_balance NUMERIC(19,4) NOT NULL,
outstanding_book_total NUMERIC(19,4) NOT NULL,
unrecorded_statement_total NUMERIC(19,4) NOT NULL,
item_count INT NOT NULL CONSTRAINT bank_reconciliation_item_count_nonnegative CHECK (item_count >= 0),
locked_by UUID NOT NULL,
locked_at TIMESTAMPTZ NOT NULL,
created_txid xid8 NOT NULL DEFAULT pg_current_xact_id(),
CONSTRAINT bank_reconciliation_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_reconciliation_once UNIQUE (org_id, bank_account_id, as_of),
CONSTRAINT bank_reconciliation_difference_zero CHECK
(statement_balance - unrecorded_statement_total + outstanding_book_total - book_balance = 0),
CONSTRAINT bank_reconciliation_bank_account_fk FOREIGN KEY (org_id, bank_account_id) REFERENCES bank_account (org_id, id),
CONSTRAINT bank_reconciliation_statement_fk FOREIGN KEY (org_id, statement_id, bank_account_id)
REFERENCES bank_statement (org_id, id, bank_account_id),
CONSTRAINT bank_reconciliation_locked_by_fk FOREIGN KEY (org_id, locked_by) REFERENCES organization_member (org_id, id)
);
CREATE TABLE bank_reconciliation_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
reconciliation_id UUID NOT NULL,
statement_line_id UUID,
journal_line_id UUID,
amount NUMERIC(19,4) NOT NULL,
CONSTRAINT bank_reconciliation_item_tenant_id UNIQUE (org_id, id),
CONSTRAINT bank_reconciliation_item_one_side CHECK (num_nonnulls(statement_line_id, journal_line_id) = 1),
CONSTRAINT bank_reconciliation_item_reconciliation_fk FOREIGN KEY (org_id, reconciliation_id) REFERENCES bank_reconciliation (org_id, id),
CONSTRAINT bank_reconciliation_item_statement_line_fk FOREIGN KEY (org_id, statement_line_id) REFERENCES bank_statement_line (org_id, id),
CONSTRAINT bank_reconciliation_item_journal_line_fk FOREIGN KEY (org_id, journal_line_id) REFERENCES journal_line (org_id, id)
);
CREATE INDEX bank_reconciliation_item_by_reconciliation ON bank_reconciliation_item (org_id, reconciliation_id);
-- RLS for all five: the 00351 block, once per table (bank_match,
-- bank_match_item, bank_unmatch, bank_reconciliation, bank_reconciliation_item).
-- … ENABLE / FORCE / CREATE POLICY org_isolation … (write all five out) …
REVOKE UPDATE, DELETE ON bank_match, bank_match_item, bank_unmatch, bank_reconciliation, bank_reconciliation_item FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION bank_locked_through(p_org UUID, p_bank_account UUID) RETURNS DATE
LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$
SELECT max(r.as_of) FROM public.bank_reconciliation r
WHERE r.org_id = p_org AND r.bank_account_id = p_bank_account
$$;
CREATE FUNCTION bank_match_active(p_org UUID, p_match UUID) RETURNS BOOLEAN
LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$
SELECT NOT EXISTS (SELECT 1 FROM public.bank_unmatch u WHERE u.org_id = p_org AND u.match_id = p_match)
$$;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE FUNCTION bank_match_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
NEW.created_txid := pg_current_xact_id();
PERFORM 1 FROM public.bank_account a WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_match_guard BEFORE INSERT ON bank_match FOR EACH ROW EXECUTE FUNCTION bank_match_guard();
-- +goose StatementBegin
CREATE FUNCTION bank_match_item_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE v_gl UUID;
BEGIN
IF (SELECT m.created_txid FROM public.bank_match m WHERE m.org_id = NEW.org_id AND m.id = NEW.match_id)
IS DISTINCT FROM pg_current_xact_id() THEN
RAISE EXCEPTION 'match %: items are written only with their match', NEW.match_id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_item_match_open';
END IF;
SELECT a.account_id INTO v_gl FROM public.bank_account a
WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE;
IF NEW.journal_line_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM public.journal_line jl
WHERE jl.org_id = NEW.org_id AND jl.id = NEW.journal_line_id AND jl.account_id = v_gl) THEN
RAISE EXCEPTION 'journal line % is not on the bank account''s GL account', NEW.journal_line_id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_item_on_account';
END IF;
IF NEW.statement_line_id IS NOT NULL AND EXISTS (
SELECT 1 FROM public.bank_statement_line l
JOIN public.bank_statement s ON s.org_id = l.org_id AND s.id = l.statement_id
WHERE l.org_id = NEW.org_id AND s.org_id = NEW.org_id AND l.id = NEW.statement_line_id
AND s.voided_at IS NOT NULL) THEN
RAISE EXCEPTION 'statement line % belongs to a voided statement', NEW.statement_line_id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_item_statement_live';
END IF;
IF EXISTS (
SELECT 1 FROM public.bank_match_item i
WHERE i.org_id = NEW.org_id AND i.match_id <> NEW.match_id
AND (i.statement_line_id = NEW.statement_line_id OR i.journal_line_id = NEW.journal_line_id)
AND public.bank_match_active(NEW.org_id, i.match_id)) THEN
RAISE EXCEPTION 'line is already in an active match'
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_item_line_free';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_match_item_guard BEFORE INSERT ON bank_match_item FOR EACH ROW EXECUTE FUNCTION bank_match_item_guard();
-- At COMMIT: the group is well-formed, cleared_on is right, and it is not
-- inside a locked reconciliation.
-- +goose StatementBegin
CREATE FUNCTION bank_match_valid() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
v_base BOOLEAN; v_ns INT; v_nb INT; v_ss NUMERIC; v_sb NUMERIC; v_ds DATE; v_db DATE;
v_first DATE; v_locked DATE; v_stmt_line UUID;
BEGIN
IF NEW.org_id IS DISTINCT FROM current_setting('app.current_org', true)::uuid THEN
RAISE EXCEPTION 'match %: checked under another organization', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_valid';
END IF;
SELECT a.currency = cp.base_currency INTO v_base
FROM public.bank_account a JOIN public.company_profile cp ON cp.org_id = a.org_id
WHERE a.org_id = NEW.org_id AND cp.org_id = NEW.org_id AND a.id = NEW.bank_account_id;
SELECT count(*), COALESCE(sum(l.amount), 0), max(s.ends_on), min(l.id::text)::uuid
INTO v_ns, v_ss, v_ds, v_stmt_line
FROM public.bank_match_item i
JOIN public.bank_statement_line l ON l.org_id = i.org_id AND l.id = i.statement_line_id
JOIN public.bank_statement s ON s.org_id = l.org_id AND s.id = l.statement_id
WHERE i.org_id = NEW.org_id AND l.org_id = NEW.org_id AND s.org_id = NEW.org_id AND i.match_id = NEW.id;
SELECT count(*),
COALESCE(sum(CASE WHEN v_base THEN jl.base_debit - jl.base_credit ELSE jl.debit - jl.credit END), 0),
max(je.entry_date)
INTO v_nb, v_sb, v_db
FROM public.bank_match_item i
JOIN public.journal_line jl ON jl.org_id = i.org_id AND jl.id = i.journal_line_id
JOIN public.journal_entry je ON je.org_id = jl.org_id AND je.id = jl.entry_id
WHERE i.org_id = NEW.org_id AND jl.org_id = NEW.org_id AND je.org_id = NEW.org_id AND i.match_id = NEW.id;
IF GREATEST(v_ds, v_db) IS DISTINCT FROM NEW.cleared_on THEN
RAISE EXCEPTION 'match %: cleared_on % is not its latest item date %', NEW.id, NEW.cleared_on, GREATEST(v_ds, v_db)
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_valid';
END IF;
IF NEW.kind = 'opening' THEN
SELECT s.starts_on INTO v_first FROM public.bank_statement s
WHERE s.org_id = NEW.org_id AND s.bank_account_id = NEW.bank_account_id AND s.voided_at IS NULL
ORDER BY s.ends_on LIMIT 1;
IF v_ns > 0 OR v_nb = 0 OR v_first IS NULL OR v_db >= v_first THEN
RAISE EXCEPTION 'match %: opening clearance takes only book lines dated before the first statement', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_opening';
END IF;
ELSIF v_ns + v_nb < 2 OR v_ss <> v_sb THEN
RAISE EXCEPTION 'match %: statement % and book % do not agree', NEW.id, v_ss, v_sb
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_balanced';
ELSIF NEW.kind = 'journal' AND NOT (v_ns = 1 AND v_nb = 1 AND EXISTS (
SELECT 1 FROM public.bank_match_item i
JOIN public.journal_line jl ON jl.org_id = i.org_id AND jl.id = i.journal_line_id
JOIN public.journal_entry je ON je.org_id = jl.org_id AND je.id = jl.entry_id
WHERE i.org_id = NEW.org_id AND jl.org_id = NEW.org_id AND je.org_id = NEW.org_id AND i.match_id = NEW.id
AND je.source_kind = 'bank_statement_line' AND je.source_id = v_stmt_line)) THEN
RAISE EXCEPTION 'match %: a journal match is one line and the entry posted from it', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'bank_match_journal_source';
END IF;
v_locked := public.bank_locked_through(NEW.org_id, NEW.bank_account_id);
IF v_locked IS NOT NULL AND NEW.cleared_on <= v_locked THEN
RAISE EXCEPTION 'match %: clears on %, inside the reconciliation locked at %', NEW.id, NEW.cleared_on, v_locked
USING ERRCODE = '23514', CONSTRAINT = 'bank_reconciliation_locked';
END IF;
RETURN NULL;
END $$;
-- +goose StatementEnd
CREATE CONSTRAINT TRIGGER bank_match_valid AFTER INSERT ON bank_match
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION bank_match_valid();
-- +goose StatementBegin
CREATE FUNCTION bank_unmatch_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE v_cleared DATE; v_locked DATE;
BEGIN
PERFORM 1 FROM public.bank_account a WHERE a.org_id = NEW.org_id AND a.id = NEW.bank_account_id FOR UPDATE;
SELECT m.cleared_on INTO v_cleared FROM public.bank_match m WHERE m.org_id = NEW.org_id AND m.id = NEW.match_id;
v_locked := public.bank_locked_through(NEW.org_id, NEW.bank_account_id);
IF v_locked IS NOT NULL AND v_cleared <= v_locked THEN
RAISE EXCEPTION 'match % cleared on %, inside the reconciliation locked at %', NEW.match_id, v_cleared, v_locked
USING ERRCODE = '23514', CONSTRAINT = 'bank_reconciliation_locked';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER bank_unmatch_guard BEFORE INSERT ON bank_unmatch FOR EACH ROW EXECUTE FUNCTION bank_unmatch_guard();
-- bank_reconciliation_guard (BEFORE INSERT): set created_txid; lock the bank
-- account; the statement is non-voided with ends_on = as_of
-- ('bank_reconciliation_statement'); as_of > bank_locked_through
-- ('bank_reconciliation_in_order').
-- bank_reconciliation_item_guard (BEFORE INSERT): its reconciliation was
-- inserted by this transaction ('bank_reconciliation_item_open').
-- bank_reconciliation_totals (DEFERRED, AFTER INSERT ON bank_reconciliation):
-- count(items) = item_count, Σ journal-side items = outstanding_book_total,
-- Σ statement-side items = unrecorded_statement_total ('bank_reconciliation_totals').
-- … write the three functions and triggers out in the shape above …
-- B9: voiding also requires no active match on the statement's lines and an
-- ends_on after the latest lock.
-- … CREATE OR REPLACE FUNCTION bank_statement_guard() — the 00351 body with,
-- after the "latest" check:
-- IF EXISTS (SELECT 1 FROM public.bank_match_item i
-- JOIN public.bank_statement_line l ON l.org_id = i.org_id AND l.id = i.statement_line_id
-- WHERE i.org_id = OLD.org_id AND l.org_id = OLD.org_id AND l.statement_id = OLD.id
-- AND public.bank_match_active(OLD.org_id, i.match_id))
-- OR OLD.ends_on <= public.bank_locked_through(OLD.org_id, OLD.bank_account_id) THEN
-- RAISE … CONSTRAINT = 'bank_statement_void_allowed';
-- END IF;
-- +goose Down
-- … CREATE OR REPLACE FUNCTION bank_statement_guard() with the 00351 body …
DROP TABLE bank_reconciliation_item; DROP TABLE bank_reconciliation; DROP TABLE bank_unmatch;
DROP TABLE bank_match_item; DROP TABLE bank_match;
-- … DROP FUNCTION for every function above …

(The … lines above are mechanical copies of blocks shown in full in this file; write every one out — no … may remain in the migration.)

match.go:

// bookLinesSQL is every journal line on the GL account in a date range, with
// its book amount (spec D2) and its active match. $3 is baseIsBank.
const bookLinesSQL = `
SELECT jl.id, je.id, je.entry_no, je.entry_date, je.description, jl.memo, je.source_kind,
CASE WHEN $3 THEN jl.base_debit - jl.base_credit ELSE jl.debit - jl.credit END,
(SELECT i.match_id FROM bank_match_item i
WHERE i.org_id = $1 AND i.journal_line_id = jl.id
AND NOT EXISTS (SELECT 1 FROM bank_unmatch u WHERE u.org_id = $1 AND u.match_id = i.match_id)
LIMIT 1)
FROM journal_line jl
JOIN journal_entry je ON je.org_id = jl.org_id AND je.id = jl.entry_id
WHERE jl.org_id = $1 AND je.org_id = $1 AND jl.account_id = $2
AND je.entry_date BETWEEN $4 AND $5`

insertMatch (the caller holds the lock): distinct ids; load each statement line (bank_account_id must be acct.ID else ErrLineForeign, statement voided → ErrVoided) with its statement’s ends_on; load each book line by id through bookLinesSQL’s projection filtered by jl.id = ANY($n) (not on acct.AccountID → ErrLineForeign); any with an active match → ErrLineTaken; for non-opening kinds Σ statement ≠ Σ book → *UnbalancedError, fewer than two items → field=lines rule=two_items; clearedOn = max of statement ends_ons and entry dates; locked := lockedThrough(…), clearedOn ≤ *locked → ErrLocked; insert bank_match then its items. MatchLines = lockBankAccount → insertMatch(KindManual) → audit. OpeningClearance checks the first non-voided statement exists and every entry date < its StartsOn (ErrNotOpening), then insertMatch(KindOpening) with no statement lines → audit. Unmatch: GetMatch → lockBankAccount → already unmatched → ErrUnmatched; ClearedOn ≤ locked → ErrLocked; reason checks; insert; audit.

MapError gains: bank_match_item_line_free → ErrLineTaken; bank_match_item_on_account → ErrLineForeign; bank_match_item_statement_live → ErrVoided; bank_match_balanced → *UnbalancedError{}; bank_match_opening → ErrNotOpening; bank_reconciliation_locked → ErrLocked; bank_unmatch_once → ErrUnmatched; bank_statement_void_allowed → ErrVoidNotAllowed; bank_match_valid, bank_match_journal_source, bank_match_item_match_open stay internal (500): Go never lets them happen.

VoidStatement gains the two Go checks (any active match on its lines; EndsOn ≤ lockedThrough) before the UPDATE.

bankingtest/match.go: (*Env).Match(t, acct, stmt, book []uuid.UUID) banking.Match and (*Env).Line(t, statementID, lineNo) uuid.UUID.

  • Step 4: Retention. appendOnly += the five tables.

  • Step 5: Run passing. go test ./internal/platform/banking/... ./internal/kernel/db/... -count=1 → PASS.

  • Step 6: Sabotage. Remove AND public.bank_match_active(NEW.org_id, i.match_id) from bank_match_item_guard; TestALineIsInOneActiveMatch fails at the re-match after unmatch (the DB now refuses a line that was only ever in an unmatched group); restore. Remove OR v_ss <> v_sb; the schema test for bank_match_balanced fails; restore. Remove the Go ErrLineTaken check; TestALineIsInOneActiveMatch still passes through MapError (backstop proven) — record that — then also remove bank_match_item_line_free’s IF and watch it go red; restore both.

  • Step 7: Commit. git add -A && git commit -m "Bank rec Task 4: match groups, unmatch, opening clearance" -m "Sabotage watched: bank_match_item_line_free (and its active clause), bank_match_balanced." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Files:

  • Modify: internal/platform/banking/automatch.go (replaces Task 3’s stub)
  • Test: internal/platform/banking/automatch_test.go (pure, package banking), internal/platform/banking/automatch_db_test.go

Interfaces:

  • Consumes: Task 4 insertMatch, bookLinesSQL, lockBankAccount.
  • Produces:
type Candidate struct {
ID uuid.UUID
Date time.Time // statement: booked_on; book: entry_date
Amount money.Amount
Text string // statement: reference+" "+bank_reference+" "+description; book: entry_no+" "+description+" "+memo
}
type Pair struct {
StatementLineID, JournalLineID uuid.UUID
Rule string
}
func AutoMatch(statement, book []Candidate, windowDays int, amountDate bool) []Pair
func RunAutoMatch(ctx context.Context, tx pgx.Tx, c clock.Clock, bankAccountID, by uuid.UUID) (int, error) // audit "auto_match"
func autoMatch(ctx context.Context, tx pgx.Tx, c clock.Clock, acct BankAccount, by uuid.UUID) ([]uuid.UUID, error)
  • Step 1: Failing tests. automatch_test.go:

    • TestReferenceRuleBeatsAmbiguity — two book lines of 250.00 on the same day, one memo SP-2026-0001 BR123; a statement line of −250 whose description has SP20260001 → paired by reference with that one, the other left.
    • TestAmountDateRuleNeedsMutualUniqueness — one statement line 100 and two book lines 100 in the window → no pair; remove one → paired amount_date; two statement lines 100 and one book line → no pair.
    • TestWindowBoundary — window 3: 3 days apart pairs, 4 days does not (presence then absence).
    • TestAmountDateRuleOff — amountDate=false: the lone-candidate case is not paired, a reference case still is.
    • TestTokens — tokens("Supplier payment SP-2026-0001") = ["SP20260001"]; "CHQ 123456" gives ["123456"]; "ABCD" and "PAYMENT" give nothing.
    • TestAutoMatchIsOrderIndependent — shuffling both inputs (fixed seed, 20 shuffles) yields the same set of pairs.

    automatch_db_test.go: TestImportAutoMatches — book SP-2026-0001 −250 and an unrelated −99.90, then Import an MT940 with −250 (ref SP-2026-0001) and −12.50 → AutoMatched = 1, the match is kind auto, rule reference; RunAutoMatch again → 0 (presence of the earlier match, nothing new); a book line posted later for −12.50 then RunAutoMatch → 1 (amount_date).

  • Step 2: Run — expect failure.

  • Step 3: Implement.

var tokenSplit = regexp.MustCompile(`[^A-Za-z0-9-]+`)
func alnum(s string) string {
return strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, strings.ToUpper(s))
}
// tokens are the reference-like words of a book line: at least five letters
// and digits, one of them a digit, hyphens dropped (banks drop them too).
func tokens(text string) []string {
var out []string
for _, t := range tokenSplit.Split(text, -1) {
if a := alnum(t); len(a) >= 5 && strings.ContainsAny(a, "0123456789") {
out = append(out, a)
}
}
return out
}
func sharesToken(statementText, bookText string) bool {
hay := alnum(statementText)
for _, t := range tokens(bookText) {
if strings.Contains(hay, t) {
return true
}
}
return false
}
// AutoMatch pairs a statement line with a book line only when, under a rule,
// each is the other's only candidate (spec D7). The reference rule runs
// first; its pairs leave the pool before the amount/date rule.
func AutoMatch(statement, book []Candidate, windowDays int, amountDate bool) []Pair {
byAmount := map[string][]Candidate{}
for _, b := range book {
byAmount[b.Amount.String()] = append(byAmount[b.Amount.String()], b)
}
used := map[uuid.UUID]bool{}
var out []Pair
run := func(rule string, ok func(s, b Candidate) bool) {
sc := map[uuid.UUID][]uuid.UUID{}
bc := map[uuid.UUID][]uuid.UUID{}
for _, s := range statement {
if used[s.ID] {
continue
}
for _, b := range byAmount[s.Amount.String()] {
if !used[b.ID] && absDays(s.Date, b.Date) <= windowDays && ok(s, b) {
sc[s.ID] = append(sc[s.ID], b.ID)
bc[b.ID] = append(bc[b.ID], s.ID)
}
}
}
for _, s := range statement {
if c := sc[s.ID]; len(c) == 1 && len(bc[c[0]]) == 1 {
out = append(out, Pair{StatementLineID: s.ID, JournalLineID: c[0], Rule: rule})
used[s.ID], used[c[0]] = true, true
}
}
}
run(RuleReference, func(s, b Candidate) bool { return sharesToken(s.Text, b.Text) })
if amountDate {
run(RuleAmountDate, func(Candidate, Candidate) bool { return true })
}
return out
}

autoMatch: unmatched statement lines of the account’s non-voided statements (all of them); if none, return. Unmatched book lines with entry_date between min(booked_on) − window and max(booked_on) + window (bookLinesSQL, MatchID IS NULL). AutoMatch(...); for each pair insertMatch(KindAuto, &rule, …), skipping (not failing) a pair refused with ErrLocked — a lock can make an otherwise-good pair unusable and that is the person’s call. RunAutoMatch = lockBankAccount → autoMatch → audit auto_match with the ids (no audit row when nothing matched).

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

  • Step 5: Sabotage. Drop && len(bc[c[0]]) == 1; TestAmountDateRuleNeedsMutualUniqueness fails at the two-statement-lines case; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 5: auto-match" -m "Sabotage watched: mutual uniqueness." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 6: Journal from an unmatched statement line

Section titled “Task 6: Journal from an unmatched statement line”

Files:

  • Create: internal/platform/banking/journal.go
  • Test: internal/platform/banking/journal_test.go

Interfaces:

  • Consumes: ledger.Post, ledger.EntryInput, chart.ListJournals, chart.JournalBank, analytic.Share, Task 4 insertMatch, lockedThrough.
  • Produces:
const SourceKindStatementLine = "bank_statement_line"
type JournalInput struct {
StatementLineID uuid.UUID
AccountID uuid.UUID // the other side: charges, interest, a transfer account
Description string // required, en/ms
EntryDate *time.Time
Distribution []analytic.Share
By uuid.UUID
}
type JournalResult struct {
Entry ledger.Entry
Match Match
}
var ErrOwnAccount = errors.New("banking: the other side cannot be the bank account itself") // 422 field=account_id rule=own_account
func PostFromLine(ctx context.Context, tx pgx.Tx, c clock.Clock, in JournalInput) (JournalResult, error)
  • Step 1: Failing tests. journal_test.go (a statement with lines −12.50 SERVICE CHARGE and +3.21 INTEREST):

    • TestPostFromLineBooksACharge — account 6180, description "Caj bank September" → an entry with semantic = manual, source_kind = bank_statement_line, source_id = the line, posted_by = Staff, dated the line’s booked_on, Dr 6180 12.50 / Cr <bank GL> 12.50, on the journal whose default account is the GL (or BNK); the line is now in a journal match with the bank-side line.
    • TestPostFromLineBooksInterest — +3.21 to an income account → Dr bank / Cr income.
    • TestPostFromLineRefusals — each after the charge case succeeds in its own env: a line already matched → ErrLineTaken (and no journal entry was written: count journal_entry WHERE source_id = line is 0 after the refused call’s rollback); AccountID = the bank GL → ErrOwnAccount; AccountID = Trade payables 2105 → the ledger’s *ledger.ControlAccountManualError; a Chinese description → recordtext field error; blank → description/required; a line of a voided statement → ErrVoided.
    • TestPostFromLineRespectsALock — after a lock at the statement’s ends_on (use Task 7’s Lock once it exists; until then insert the bank_reconciliation row as the admin in the test with difference 0), the default date → ErrLocked; EntryDate = ends_on + 1 succeeds.
  • Step 2: Run — expect failure.

  • Step 3: Implement. In order: read the line (bank_account_id, booked_on, amount, statement voided_at); lockBankAccount; re-read under the lock; voided → ErrVoided; active match → ErrLineTaken; in.AccountID == acct.AccountID → ErrOwnAccount; description required, 1–500 characters, recordtext.Check("description", …); date := line.BookedOn or *in.EntryDate (midnight UTC); locked != nil && !date.After(*locked) → ErrLocked; journalID, journalType := journalFor(ctx, tx, acct.AccountID) (copied from payables.journalFor, with the same comment); lines:

amt := line.Amount
bank := ledger.Line{AccountID: acct.AccountID, Debit: money.Zero(), Credit: money.Zero()}
other := ledger.Line{AccountID: in.AccountID, Debit: money.Zero(), Credit: money.Zero(), Distribution: in.Distribution}
if amt.IsPositive() { // money in: interest, a receipt not yet booked
bank.Debit, other.Credit = amt, amt
} else {
bank.Credit, other.Debit = amt.Neg(), amt.Neg()
}
lineID := line.ID
entry, err := ledger.Post(ctx, tx, c, ledger.EntryInput{
JournalID: journalID, JournalType: journalType, EntryDate: date, Currency: acct.Currency,
Description: in.Description, Semantic: ledger.SemanticManual,
SourceKind: SourceKindStatementLine, SourceID: &lineID, PostedBy: &in.By,
Lines: []ledger.Line{bank, other},
})

then the bank-side posted line is entry.Lines[0] (Post keeps input order; assert AccountID to be safe); insertMatch(KindJournal, nil, []uuid.UUID{line.ID}, []uuid.UUID{entry.Lines[0].ID}); audit (bank_match, journal, After: {match, entry_id}). ledger.Post’s own errors pass through unchanged (the handler maps them with ledger.MapError via banking.MapError).

  • Step 4: Run passing.

  • Step 5: Sabotage. Skip the active-match check in Go; TestPostFromLineRefusals’s matched-line case still refuses, at insertMatch or COMMIT through bank_match_item_line_free → ErrLineTaken, and its “no journal entry” count still holds because the whole transaction rolls back — record that; restore. Pass KindManual instead of KindJournal to insertMatch; the charge test’s kind journal assertion fails; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 6: journal from a statement line through ledger.Post" -m "Sabotage watched: in-Go line-taken check (DB backstop holds)." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Files:

  • Create: internal/platform/banking/reconciliation.go
  • Test: internal/platform/banking/reconciliation_test.go, internal/platform/banking/reconciliation_property_test.go

Interfaces:

  • Consumes: Tasks 3–4.
  • Produces:
type RecItem struct {
StatementLineID, JournalLineID *uuid.UUID
Date time.Time // statement line: booked_on; book line: entry_date
EntryNo string // book lines
Text string // statement: description; book: entry description
Amount money.Amount
}
type LockInfo struct {
ID uuid.UUID
AsOf time.Time
LockedBy uuid.UUID
LockedAt time.Time
}
type Reconciliation struct {
BankAccountID, StatementID uuid.UUID
AsOf time.Time
Currency string
StatementBalance, BookBalance money.Amount
OutstandingBook, UnrecordedStatement []RecItem
OutstandingBookTotal, UnrecordedStatementTotal money.Amount
Adjusted, Difference money.Amount
Locked *LockInfo
}
var (
ErrNoStatementOnDate = errors.New("banking: no statement ends on that date") // 422 field=as_of rule=statement_date
ErrNotInOrder = errors.New("banking: a later date is already locked") // 409 not_after_lock
ErrDifference = errors.New("banking: the difference must be zero to lock") // 409 difference_not_zero
)
func Reconcile(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID, asOf time.Time) (Reconciliation, error)
func Lock(ctx context.Context, tx pgx.Tx, c clock.Clock, bankAccountID uuid.UUID, asOf time.Time, by uuid.UUID) (Reconciliation, error)
func ListLocked(ctx context.Context, tx pgx.Tx, bankAccountID uuid.UUID) ([]LockInfo, error) // as_of DESC
  • Step 1: Failing tests.

reconciliation_test.go — the worked example (MYR bank, opening 1 000.00 on 2026-09-01 — first give it an opening clearance: book +1000 dated 2026-08-31 cleared by OpeningClearance):

  • Book: cheque −300 (09-10), receipt +800 (09-28), payment −250 (09-15).
  • Statement to 09-30: −250 (09-16, ref matches), +50 interest (09-30), −12.50 charge (09-30); closing 787.50.
  • Auto-match pairs the −250. Expect as of 09-30: S = 787.50, B = 1000 − 300 + 800 − 250 = 1250, outstanding book = [−300, +800] (Σ 500), unrecorded statement = [+50, −12.50] (Σ 37.50), Adjusted = 787.50 − 37.50 + 500 = 1250, Difference = 0.
  • TestLockRefusedWithADifference — presence: the example locks; in a twin env without the opening clearance, Difference = 1000 (the first opening) and Lock → ErrDifference.
  • TestLockFreezes — after the lock at 09-30: Reconcile(09-30) returns Locked non-nil and the stored items; posting another book line dated 09-29 changes a fresh recomputation (checked by calling the unexported compute) but not the locked result; Unmatch of the −250 match (cleared_on 09-30) → ErrLocked; VoidStatement of the 09-30 statement → ErrVoidNotAllowed; PostFromLine on the −12.50 line with the default date → ErrLocked; a new statement to 10-31 with the cheque −300 on it imports and auto-matches (the group clears 10-31, straddling the lock) — presence of the allowed case after the refusals.
  • TestLockInOrder — lock 10-31 then 09-30 → ErrNotInOrder; a date that is no statement’s ends_on → ErrNoStatementOnDate for both Reconcile and Lock.
  • TestLockIsStoredAndAudited — one bank_reconciliation, item_count = 4, one lock audit row.

reconciliation_property_test.go — TestDifferenceIsInvariantUnderMatching: fixed seed (rand.New(rand.NewPCG(1, 2))), build two statements and ~20 book lines where half the statement lines have equal-amount book twins; perform 60 random operations (match a random unmatched twin pair, contra-match an entry with its reversal, unmatch a random active match); after every operation assert Reconcile(asOf).Difference equals firstOpening − Σ opening-cleared for both statement dates, and that S − ΣU_s + ΣU_b − B equals Difference (the identity the CHECK encodes).

  • Step 2: Run — expect failure.

  • Step 3: Implement. compute(ctx, tx, acct, stmt, asOf) runs three queries (each with org_id = $1 on every table):

-- B
SELECT COALESCE(sum(CASE WHEN $3 THEN jl.base_debit - jl.base_credit ELSE jl.debit - jl.credit END), 0)
FROM journal_line jl JOIN journal_entry je ON je.org_id = jl.org_id AND je.id = jl.entry_id
WHERE jl.org_id = $1 AND je.org_id = $1 AND jl.account_id = $2 AND je.entry_date <= $4;
-- U_b: book lines to D not reconciled as of D (spec D11)
SELECT jl.id, je.entry_date, je.entry_no, je.description,
CASE WHEN $3 THEN jl.base_debit - jl.base_credit ELSE jl.debit - jl.credit END
FROM journal_line jl JOIN journal_entry je ON je.org_id = jl.org_id AND je.id = jl.entry_id
WHERE jl.org_id = $1 AND je.org_id = $1 AND jl.account_id = $2 AND je.entry_date <= $4
AND NOT EXISTS (
SELECT 1 FROM bank_match_item i JOIN bank_match m ON m.org_id = i.org_id AND m.id = i.match_id
WHERE i.org_id = $1 AND m.org_id = $1 AND i.journal_line_id = jl.id AND m.cleared_on <= $4
AND NOT EXISTS (SELECT 1 FROM bank_unmatch u WHERE u.org_id = $1 AND u.match_id = m.id))
ORDER BY je.entry_date, je.entry_no, jl.line_no;
-- U_s: lines of live statements ending by D, not reconciled as of D
SELECT l.id, l.booked_on, l.description, l.amount
FROM bank_statement_line l JOIN bank_statement s ON s.org_id = l.org_id AND s.id = l.statement_id
WHERE l.org_id = $1 AND s.org_id = $1 AND s.bank_account_id = $2 AND s.voided_at IS NULL AND s.ends_on <= $3
AND NOT EXISTS (
SELECT 1 FROM bank_match_item i JOIN bank_match m ON m.org_id = i.org_id AND m.id = i.match_id
WHERE i.org_id = $1 AND m.org_id = $1 AND i.statement_line_id = l.id AND m.cleared_on <= $3
AND NOT EXISTS (SELECT 1 FROM bank_unmatch u WHERE u.org_id = $1 AND u.match_id = m.id))
ORDER BY s.ends_on, l.line_no;

Adjusted = S − ΣU_s + ΣU_b; Difference = Adjusted − B. Reconcile: the account; the non-voided statement with ends_on = asOf (else ErrNoStatementOnDate); if a bank_reconciliation exists at asOf, load it and its items (joined for date/text/entry number) and return it with Locked; else compute. Lock: lockBankAccount; statement check; locked := lockedThrough, !asOf.After(*locked) → ErrNotInOrder; compute; !Difference.IsZero() → ErrDifference; insert header (id = uuid.New()) and items (one unnest insert); audit lock (totals and item count, not the item list); return Reconcile of the same date. MapError gains bank_reconciliation_once/bank_reconciliation_in_order → ErrNotInOrder, bank_reconciliation_statement → ErrNoStatementOnDate, bank_reconciliation_difference_zero → ErrDifference.

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

  • Step 5: Sabotage. In the U_b query drop AND m.cleared_on <= $4; the worked example still passes but TestLockFreezes’s straddling step or the property test fails (a book line matched to a later statement stops being outstanding at the earlier date, and the difference moves) — read which; restore. Remove the Go ErrDifference check; TestLockRefusedWithADifference still refuses via bank_reconciliation_difference_zero (backstop proven); restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 7: reconciliation statement and lock" -m "Sabotage watched: cleared_on clause (property test), difference CHECK backstop." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 8: The banking module — permissions, grants, wiring, bank-account routes

Section titled “Task 8: The banking module — permissions, grants, wiring, bank-account routes”

Files:

  • Create: internal/modules/banking/{permissions.go,routes.go,http.go,errors.go,account_handlers.go}, db/migrations/00353_banking_role_grants.sql
  • Modify: constants.yaml (permissions: six keys) + generated vocab, api/openapi.yaml, internal/app/app.go, internal/app/routes_test.go, cmd/api/main.go, cmd/migrate/main.go, cmd/orgadmin/main.go, cmd/worker/main.go, cmd/{migrate,orgadmin,worker}/wiring_test.go
  • Test: internal/modules/banking/{env_test.go,account_handlers_test.go,routes_test.go,grants_test.go}, internal/platform/banking/migration00353_test.go

Interfaces:

  • Consumes: Task 2; httpx.Protected, member.RegisterPermission, member.Permissions, provisioning.RegisterRolePermissions, i18n.Load.
  • Produces:
const (
PermissionRead = "banking.read"
PermissionAccountManage = "banking.account.manage"
PermissionStatementImport = "banking.statement.import"
PermissionMatch = "banking.match"
PermissionJournalPost = "banking.journal.post"
PermissionReconciliationLock = "banking.reconciliation.lock"
)
type Deps struct{ DB *db.DB; Clock clock.Clock }
func Register(g *gin.RouterGroup, deps Deps)

This task’s routes: GET/POST /banking/accounts, GET/PUT /banking/accounts/:id (Task 9 adds the rest to the same Register).

  • Step 1: Failing tests. env_test.go: copy internal/modules/payables/env_test.go’s harness (newEnv, grant, mint, do, decode) with bx *bankingtest.Env and banking.Register(api, banking.Deps{DB: bx.DB, Clock: bx.Clock}); members clerk = Staff granted every key except PermissionReconciliationLock plus accounting.ledger.read, owner = Owner granted all six, reader granted PermissionRead only. account_handlers_test.go: create (201, ETag: "1", account_label is the translated GL name, currency derived), list, get, put with If-Match (200 → "2"; stale → 409 with revision; missing → 400), 422 field=account_id rule=bank_account, 409 code=already_linked, the reader’s POST → 403. routes_test.go: every route’s permission pinned (the payables routes_test.go table form). grants_test.go: a newly provisioned org’s Finance role has the five keys and not banking.reconciliation.lock (presence first: it has banking.read); Owner has all six. migration00353_test.go: testdb.StartAt(t, 352) with an org whose Owner and Finance roles exist, migrate to 353, assert the grants and the perm_version bump (the migration00305_test.go shape).

  • Step 2: Run — expect failure.

  • Step 3: Implement.

permissions.go:

func init() {
member.RegisterPermission(PermissionRead, "Read bank accounts, statements and their files, matches and reconciliations")
member.RegisterPermission(PermissionAccountManage, "Link bank accounts to the chart and edit their numbers, match settings and CSV mapping")
member.RegisterPermission(PermissionStatementImport, "Import bank statements and void the latest one")
member.RegisterPermission(PermissionMatch, "Auto-match, match and unmatch statement lines and ledger lines")
member.RegisterPermission(PermissionJournalPost, "Post a journal for a bank charge, interest or transfer from a statement line")
member.RegisterPermission(PermissionReconciliationLock, "Lock a bank reconciliation and clear pre-go-live ledger lines")
// Spec D10: Finance reconciles; locking and opening clearance stay Owner-only.
provisioning.RegisterRolePermissions("Finance",
PermissionRead, PermissionAccountManage, PermissionStatementImport, PermissionMatch, PermissionJournalPost)
}

db/migrations/00353_banking_role_grants.sql: 00305’s shape — Owners get the six keys, Finance the five, ON CONFLICT DO NOTHING, the perm_version bump; Down deletes banking.% from both and bumps.

constants.yaml: the six keys under permissions: after the payables block; make generate.

api/openapi.yaml: tag banking; schemas BankAccount (id, account_id, account_code, account_label, currency, account_number, match_window_days, match_on_amount_date, csv_mapping (CSVMapping | null), is_active, revision, created_at), BankAccountInput, CSVMapping (every field of Task 1’s struct; delimiter enum; date_format enum of the seven keys); the four operations, each security: [bearerAuth: []] and a description ending “Requires banking.…”. make generate.

account_handlers.go, http.go, errors.go: the payables handlers’ conventions (decodeStrict, ifMatch, setETag, firstErr, actor(ctx)), copied not imported (a module does not import another module). mapErr maps *banking.FieldError → 422 field=… rule=…, *banking.RevisionMismatchError → 409 with revision, ErrAlreadyLinked → 409 code=already_linked, ErrNotFound → 404, ErrInactive → 409 code=inactive; the rest of the table grows in Task 9.

Wiring: every place payables is composed (grep -rn "modules/payables" internal/app cmd), add banking beside it — app.Router calls banking.Register, composedModules lists it, each cmd/*/main.go blank-imports or composes it exactly as it does payables, each wiring_test.go gains TestWorkerLinksTheBankingModule etc. mirroring the payables one.

  • Step 4: Run passing. go test ./internal/modules/banking/... ./internal/platform/banking/... ./internal/app/... ./cmd/... ./internal/kernel/... -count=1 → PASS (TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, the wiring tests, TestEveryTenantQueryCarriesAnExplicitOrgPredicate). git status clean after make generate.

  • Step 5: Sabotage. Add PermissionReconciliationLock to the Finance registration; grants_test.go fails at its absence assertion; restore. Remove banking.Register from app.Router; TestEveryModuleContributesRoutesAndPermissions fails naming banking and the OpenAPI test lists the four routes as phantom; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 8: banking module, permissions, grants, bank-account routes" -m "Sabotage watched: Finance lock absence; module composition." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 9: Statement, matching, journal and reconciliation routes

Section titled “Task 9: Statement, matching, journal and reconciliation routes”

Files:

  • Create: internal/modules/banking/{statement_handlers.go,match_handlers.go,reconciliation_handlers.go}
  • Modify: internal/modules/banking/{routes.go,errors.go}, api/openapi.yaml (+ generated)
  • Test: internal/modules/banking/{statement_handlers_test.go,match_handlers_test.go,reconciliation_handlers_test.go}, internal/modules/banking/routes_test.go (extend)

Interfaces:

  • Consumes: Tasks 3–7.
  • Produces: the remaining routes of the spec’s API table:
Method Path Permission
GET / POST /banking/accounts/:id/statements read / statement.import
GET /banking/statements/:id read
GET /banking/statements/:id/file read
POST /banking/statements/:id/void statement.import
GET /banking/accounts/:id/book-lines read
POST /banking/accounts/:id/auto-match match
POST /banking/matches match
POST /banking/matches/:id/unmatch match
POST /banking/accounts/:id/opening-clearance reconciliation.lock
POST /banking/statement-lines/:id/journal journal.post
GET /banking/accounts/:id/reconciliation read
GET / POST /banking/accounts/:id/reconciliations read / reconciliation.lock
  • Step 1: Failing tests. statement_handlers_test.go: import an MT940 ({"format":"mt940","file_name":"sep.sta","content_base64":…}) → 201 with statement and auto_matched; the same body → 409 code=duplicate_file with statement_id; bad base64 → 400; > 7 MiB body → 400 field=file rule=size (apperr has no too-large kind; the handler turns *http.MaxBytesError from the decoder into that 400); a broken :61: → 422 detail starting field=file rule=mt940_syntax: line; the file download returns the exact bytes with Content-Type: application/octet-stream and Content-Disposition: attachment; filename="sep.sta"; void → 200 and the statement shows voided_at; the reader’s import → 403. match_handlers_test.go: book-lines list (B11 defaults; truncated), manual match 201 / 422 rule=unbalanced / 409 code=line_taken, unmatch 200 / 409 code=reconciliation_locked, auto-match 200 {"matched": n}, opening clearance by the clerk → 403 and by the owner → 201, journal 201 with entry and match, journal onto 2105 → the ledger’s 422/409 exactly as POST /accounting/entries answers it. reconciliation_handlers_test.go: the Task 7 worked example over HTTP — GET as of 09-30 (all money as strings, difference: "0.0000"), the clerk’s lock → 403, the owner’s lock → 201, GET again → locked non-null, list → one row; a lock with a difference → 409 code=difference_not_zero; as_of not a statement date → 422 field=as_of rule=statement_date.

  • Step 2: Run — expect failure.

  • Step 3: Implement. Handlers follow Task 8’s conventions; each opens one db.InTx, calls one platform function, and maps banking.MapError(err) then mapErr. The import handler:

func (h handlers) importStatement(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 7<<20)
var body struct {
Format string `json:"format"`
FileName string `json:"file_name"`
ContentBase64 string `json:"content_base64"`
StartsOn *string `json:"starts_on"`
EndsOn *string `json:"ends_on"`
OpeningBalance *money.Amount `json:"opening_balance"`
ClosingBalance *money.Amount `json:"closing_balance"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
file, err := base64.StdEncoding.DecodeString(body.ContentBase64)
if err != nil {
_ = c.Error(apperr.New(apperr.KindBadRequest, "content_base64 is not base64"))
return
}
// … parse the two optional dates (dateLayout), then
// banking.Import(ctx, tx, h.deps.Clock, banking.ImportInput{…, By: *actor(ctx)}) inside InTx …
}

errors.go completes the table: *DuplicateFileError → 409 code=duplicate_file + statement_id; *OpeningMismatchError → 422 field=opening_balance rule=chain + expected; ErrNotAfterPrevious → 409 code=not_after_previous; ErrNoCSVMapping → 422 code=no_csv_mapping; *ParseError → 422 field=file rule=<rule>: line N: …; ErrVoidNotAllowed → 409 code=void_not_allowed; ErrVoided → 409 code=voided; *UnbalancedError → 422 field=lines rule=unbalanced + both sums; ErrLineTaken → 409 code=line_taken; ErrLineForeign → 422 field=lines rule=foreign; ErrLocked → 409 code=reconciliation_locked; ErrUnmatched → 409 code=already_unmatched; ErrNotOpening → 422 field=journal_line_ids rule=opening; ErrOwnAccount → 422 field=account_id rule=own_account; ErrNoStatementOnDate → 422 field=as_of rule=statement_date; ErrNotInOrder → 409 code=not_after_lock; ErrDifference → 409 code=difference_not_zero; anything from ledger → the same mapping internal/modules/accounting/errors.go applies (copy the cases this path can hit: period not open, account not postable/inactive, control/owned account on a manual line, journal inactive/currency, distribution errors, record language).

OpenAPI: schemas Statement, StatementLine (with match, match_history per B12), StatementImport, ImportResult, BookLine, BookLineList, MatchInput, Match, UnmatchInput, OpeningClearanceInput, StatementLineJournalInput (account_id, description, entry_date?, distribution? — reuse the manual journal’s distribution schema), StatementLineJournalResult, Reconciliation, RecItem, ReconciliationLockInput, LockInfo; every money field $ref: Money. make generate.

  • Step 4: Run passing. go test ./internal/modules/banking/... ./internal/app/... -count=1 → PASS; git status clean after make generate.

  • Step 5: Sabotage. Register /banking/accounts/:id/opening-clearance under PermissionMatch; routes_test.go fails naming the route, and the clerk’s 403 assertion fails; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 9: statement, matching, journal and reconciliation routes" -m "Sabotage watched: opening-clearance permission." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 10: Web — API hooks, translations, nav, bank accounts page

Section titled “Task 10: Web — API hooks, translations, nav, bank accounts page”

Files:

  • Create: web/src/features/banking/{api.ts,routes.tsx,BankAccountsPage.tsx,BankAccountDialog.tsx,CsvMappingFields.tsx,base64.ts}, web/src/lib/i18n/locales/{en,ms,zh}/banking.json
  • Modify: web/src/app/shell/nav.ts, web/src/app/routes.ts
  • Test: web/src/features/banking/__tests__/{BankAccountsPage.test.tsx,base64.test.ts}

Interfaces:

  • Consumes: the generated schema.d.ts types (Schemas['BankAccount'] …), api, unwrap, DataTable (tableId), Dialog, Combobox, Field, Input, Checkbox, Select, RequirePermission, useAccounts from web/src/features/accounting/queries.ts.
  • Produces: every hook Tasks 11–12 use, so those two can run in parallel:
export const bankingKeys = {
all: ['banking'] as const,
accounts: ['banking', 'accounts'] as const,
account: (id: string) => ['banking', 'account', id] as const,
statements: (id: string) => ['banking', 'statements', id] as const,
statement: (id: string) => ['banking', 'statement', id] as const,
bookLines: (id: string, f: object) => ['banking', 'bookLines', id, f] as const,
reconciliation: (id: string, asOf: string) => ['banking', 'rec', id, asOf] as const,
locks: (id: string) => ['banking', 'locks', id] as const,
};
useBankAccounts(); useBankAccount(id); useSaveBankAccount();
useStatements(accountId); useStatement(id); useImportStatement(accountId); useVoidStatement(id);
useBookLines(accountId, filter); useAutoMatch(accountId); useMatch(accountId); useUnmatch(accountId);
useOpeningClearance(accountId); usePostFromLine(accountId);
useReconciliation(accountId, asOf); useLocks(accountId); useLockReconciliation(accountId);
// every mutation invalidates bankingKeys.all (the lists are small; precise invalidation is not worth the bugs)
export async function fileToBase64(f: File): Promise<string> // base64.ts: bytes → base64 in 32 KiB chunks via String.fromCharCode + btoa
  • Step 1: Failing tests. base64.test.ts: a File of bytes [0, 255, 1, 2] and one of 100 000 random bytes round-trip through atob. BankAccountsPage.test.tsx (MSW): the list renders account_code — account_label and the masked-free number; the New dialog lists only GL accounts with control_kind === 'bank', is_postable, active, and not already linked; choosing CSV “debit and credit columns” shows the two column inputs and hides the amount one; saving posts the mapping with numbers (not strings) for columns; a 422 field=account_number shows beside that field.

  • Step 2: Run — expect failure. pnpm -C web test --run src/features/banking → FAIL.

  • Step 3: Implement. nav.ts: after the payables items, { group: 'accounting', to: '/banking', labelKey: 'banking:nav.bankAccounts', icon: <the Landmark / bank icon the nav's icon set has>, permission: 'banking.read' }. routes.ts: spread bankingRoutes. routes.tsx: /banking (list, RequirePermission banking.read), /banking/new and /banking/$id/edit (dialog children), /banking/$id (account page shell with tab search param, z.enum(['statements','matching','reconciliation']).catch('statements'); Tasks 11–12 fill the tabs — this task renders the Tabs with placeholder panels).

banking.json — en / ms / zh (Malay as given; zh natural Simplified Chinese):

key en ms
nav.bankAccounts Bank accounts Akaun bank
accounts.title Bank accounts Akaun bank
accounts.new Link bank account Pautkan akaun bank
accounts.glAccount Ledger account Akaun lejar
accounts.number Account number Nombor akaun
accounts.currency Currency Mata wang
accounts.window Match window (days) Tempoh padanan (hari)
accounts.amountDate Match on amount and date alone Padankan dengan amaun dan tarikh sahaja
accounts.inactive Inactive Tidak aktif
csv.title CSV layout Susun atur CSV
csv.delimiter Separator Pemisah
csv.headerRows Header rows to skip Baris pengepala dilangkau
csv.footerRows Footer rows to skip Baris pengaki dilangkau
csv.dateColumn Date column Lajur tarikh
csv.dateFormat Date format Format tarikh
csv.valueDateColumn Value date column Lajur tarikh nilai
csv.descriptionColumns Description columns Lajur keterangan
csv.referenceColumn Reference column Lajur rujukan
csv.amountMode Amounts Amaun
csv.signed One signed column Satu lajur bertanda
csv.debitCredit Debit and credit columns Lajur debit dan kredit
csv.amountColumn Amount column Lajur amaun
csv.debitColumn Debit column Lajur debit
csv.creditColumn Credit column Lajur kredit
csv.decimalComma Decimal comma (1.234,56) Koma perpuluhan (1.234,56)
csv.balanceColumn Running balance column Lajur baki berjalan
csv.newestFirst Newest line first Baris terbaharu dahulu
csv.columnHint Columns are numbered from 1; 0 means none. Lajur dinomborkan dari 1; 0 bermaksud tiada.
tabs.statements Statements Penyata
tabs.matching Matching Padanan
tabs.reconciliation Reconciliation Penyesuaian
statements.import Import statement Import penyata
statements.file Statement file Fail penyata
statements.format Format Format
statements.period Period Tempoh
statements.startsOn From Dari
statements.endsOn To Hingga
statements.opening Opening balance Baki awal
statements.closing Closing balance Baki akhir
statements.lines Lines Baris
statements.importedBy Imported by Diimport oleh
statements.download Download file Muat turun fail
statements.void Void statement Batalkan penyata
statements.voidReason Why is it being voided? Mengapa penyata ini dibatalkan?
statements.voided Voided Dibatalkan
statements.autoMatched {{count}} lines matched automatically {{count}} baris dipadankan secara automatik
statements.duplicate This file was already imported. Fail ini telah diimport.
matching.statementLines Bank statement lines Baris penyata bank
matching.bookLines Ledger lines Baris lejar
matching.autoMatch Auto-match Padanan automatik
matching.match Match Padankan
matching.unmatch Unmatch Nyahpadan
matching.unmatchReason Why are these being unmatched? Mengapa padanan ini dibatalkan?
matching.selectedTotal Selected: bank {{bank}}, ledger {{book}} Dipilih: bank {{bank}}, lejar {{book}}
matching.mustAgree The two totals must agree. Kedua-dua jumlah mesti sama.
matching.postToAccount Post to account Catat ke akaun
matching.postTitle Post this line to the ledger Catat baris ini ke lejar
matching.otherAccount Account Akaun
matching.description Description Keterangan
matching.defaultCharge Bank charges Caj bank
matching.defaultInterest Bank interest Faedah bank
matching.openingClearance Clear pre-go-live lines Jelaskan baris sebelum mula guna
matching.openingHint Only ledger lines dated before the first statement, already reflected in its opening balance. Hanya baris lejar bertarikh sebelum penyata pertama, yang sudah termasuk dalam baki awalnya.
matching.history Match history Sejarah padanan
matching.kind.auto Automatic Automatik
matching.kind.manual Manual Manual
matching.kind.journal Posted from line Dicatat dari baris
matching.kind.opening Opening clearance Penjelasan awal
rec.asOf Statement date Tarikh penyata
rec.statementBalance Balance per bank statement Baki mengikut penyata bank
rec.unrecorded Less: bank items not in the ledger Tolak: butiran bank yang tiada dalam lejar
rec.outstanding Add: ledger items not yet on the statement Tambah: butiran lejar yang belum ada dalam penyata
rec.adjusted Adjusted bank balance Baki bank terlaras
rec.bookBalance Balance per ledger Baki mengikut lejar
rec.difference Difference Perbezaan
rec.differenceHint A difference comes only from pre-go-live lines not yet cleared. Perbezaan hanya berpunca daripada baris sebelum mula guna yang belum dijelaskan.
rec.lock Lock reconciliation Kunci penyesuaian
rec.lockConfirm Once locked, matches up to this date cannot change. This cannot be undone. Setelah dikunci, padanan sehingga tarikh ini tidak boleh diubah. Tindakan ini tidak boleh dibatalkan.
rec.locked Locked by {{name}} on {{date}} Dikunci oleh {{name}} pada {{date}}
rec.empty Import a statement to reconcile. Import penyata untuk membuat penyesuaian.

(zh: 银行账户 / 关联银行账户 / 总账科目 / 账号 / 币种 / 匹配期限(天)/ … — translate every key; the parity test fails on a missing one.)

  • Step 4: Run passing. pnpm -C web test --run (the banking tests, i18nparity, nofloat) and pnpm -C web typecheck → PASS.

  • Step 5: Sabotage. Delete matching.mustAgree from ms; i18nparity.test.ts fails naming it; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 10: web hooks, translations, bank accounts page" -m "Sabotage watched: i18n parity." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 11: Web — statements and matching tabs

Section titled “Task 11: Web — statements and matching tabs”

Files:

  • Create: web/src/features/banking/{StatementsTab.tsx,ImportStatementDialog.tsx,MatchingTab.tsx,PostFromLineDialog.tsx,UnmatchDialog.tsx,OpeningClearanceDialog.tsx}
  • Test: web/src/features/banking/__tests__/{StatementsTab.test.tsx,MatchingTab.test.tsx}

Interfaces:

  • Consumes: Task 10 hooks; sumMoney, compareMoney, formatMoney; RecordLanguageHint from web/src/features/accounting/entries/RecordLanguageHint.tsx.

  • Produces: the two tab components, mounted by Task 10’s account page.

  • Step 1: Failing tests. StatementsTab.test.tsx: the list shows period, opening, closing, lines, Voided chip; Import with an .sta file posts format: 'mt940' and base64 content, then toasts statements.autoMatched; a .csv file switches to CSV and shows from/to (defaulting empty) and opening/closing fields; a 409 duplicate_file shows statements.duplicate; Void needs a reason and sends it; Download calls the file route. MatchingTab.test.tsx: selecting a −250 statement line and a −250 ledger line enables Match; −250 against −249.99 shows matching.mustAgree and Match stays disabled (the comparison is compareMoney, no float); Auto-match posts and invalidates; “Post to account” on a −12.50 line pre-fills Bank charges (en record locale) and lists only accounts that are postable, active, not a sub-ledger control (ap, ap_trade, ar, employee_advances, petty_cash_ap), not module-owned and not this bank’s GL; Unmatch from the line’s history needs a reason; Opening clearance is hidden without banking.reconciliation.lock.

  • Step 2: Run — expect failure.

  • Step 3: Implement. Two DataTables side by side (stacked below 768 px; tableId banking.statementLines / banking.bookLines), each with row checkboxes, a date filter on the ledger side (defaults: B11), and a sticky footer with matching.selectedTotal. The statement-line table shows only lines with match == null by default, with a toggle to show all (matched rows show the kind chip and open the history). Format detection: extension .csv → CSV, anything else → MT940, with a select to override. The journal dialog’s description is a record field: an en/ms selector next to it sets which default phrase is filled (B13), and RecordLanguageHint sits under it.

  • Step 4: Run passing. pnpm -C web test --run src/features/banking && pnpm -C web typecheck → PASS.

  • Step 5: Sabotage. Replace compareMoney(a, b) === 0 with Number(a) === Number(b); nofloat.test.ts fails naming the file; restore.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 11: statements and matching tabs" -m "Sabotage watched: nofloat." -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Task 12: Web — reconciliation tab and lock

Section titled “Task 12: Web — reconciliation tab and lock”

Files:

  • Create: web/src/features/banking/ReconciliationTab.tsx
  • Test: web/src/features/banking/__tests__/ReconciliationTab.test.tsx

Interfaces:

  • Consumes: Task 10 hooks (useStatements, useReconciliation, useLocks, useLockReconciliation), ConfirmDialog, formatMoney.

  • Step 1: Failing tests. The date select lists the non-voided statements’ ends_on, newest first, defaulting to the newest (B15); the statement renders the five lines of spec D11 in order with the two item lists (date, entry number / reference, text, amount); Difference non-zero shows rec.differenceHint and disables Lock; with a zero difference and banking.reconciliation.lock, Lock opens rec.lockConfirm and posts {as_of}; a locked date shows rec.locked and no Lock button; without the permission there is no Lock button at all.

  • Step 2: Run — expect failure.

  • Step 3: Implement. A two-column statement layout (label | amount) with the item lists as nested tables under their lines; print-friendly (@media print hides the shell — the existing print:hidden utility classes if the shell has them; otherwise skip, spec “Out”).

  • Step 4: Run passing.

  • Step 5: Commit. git add -A && git commit -m "Bank rec Task 12: reconciliation tab and lock" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" (no new guard in this task).


Task 13: End to end on lane 4, dev seed, roadmap, deploy notes, full suite

Section titled “Task 13: End to end on lane 4, dev seed, roadmap, deploy notes, full suite”

Files:

  • Modify: scripts/dev-stack.sh, web/playwright.config.ts, web/vite.config.ts (B18, only if not already parametrised), web/e2e/seed.ts (Finance gains the five banking keys), web/e2e/support.ts (ensureFinanceBanking), docs/ROADMAP.md, docs/DEPLOY_NOTES.md
  • Create: web/e2e/banking.spec.ts

Interfaces:

  • Consumes: the whole feature; as, api, runTag from web/e2e/support.ts.

  • Produces: ensureFinanceBanking(): Promise<void> and the flow.

  • Step 1: Lane ports (B18). In scripts/dev-stack.sh: PG_PORT=${PG_PORT:-55432}, MINIO_PORT=${MINIO_PORT:-59000}, API_PORT=${API_PORT:-3000}, WEB_PORT=${WEB_PORT:-5173}, STACK=${STACK:-dev}; container names erp-$STACK-pg / erp-$STACK-minio; state dir .dev-stack when STACK=dev, else .dev-stack-$STACK; PORT=$API_PORT, PUBLIC_BASE_URL=http://localhost:$WEB_PORT, the health URL and the “running” line use $API_PORT, the seed’s API_BASE too. web/vite.config.ts proxies to http://localhost:${process.env.API_PORT ?? 3000}. web/playwright.config.ts uses E2E_WEB_PORT (default 5173) for baseURL/the vite command and E2E_API_PORT (default 3000) for the health URL, and passes the lane variables through to the script. Defaults unchanged, so pnpm -C web e2e without variables behaves as today.

  • Step 2: Write the e2e (it fails until the seed grants Finance).

web/e2e/support.ts:

export async function ensureFinanceBanking() {
// The dev org may predate this item: give Finance its banking keys once (spec D10), as the owner.
const owner = await api('owner');
// … find the Finance role through GET /org/roles, PUT its permission set ∪ the five banking keys
// (the ensureFinanceAP shape) …
}

web/e2e/banking.spec.ts:

import { expect, test } from '@playwright/test';
import { api, as, ensureFinanceBanking, runTag } from './support';
test.beforeAll(async () => {
await ensureFinanceBanking();
});
function mt940(account: string, day: string, receiptRef: string): string {
const d = day.replaceAll('-', '').slice(2); // YYMMDD
const md = d.slice(2);
return [
':20:E2E', `:25:MBBEMYKL/${account}`, ':28C:1/1', `:60F:C${d}MYR0,00`,
`:61:${d}${md}C500,00NTRF${receiptRef}`, `:86:DUITNOW ${receiptRef}`,
`:61:${d}${md}D12,50NCHGNONREF`, ':86:SERVICE CHARGE',
`:62F:C${d}MYR487,50`, '-',
].join('\n');
}
test('link → book a receipt → import MT940 → auto-match → journal the charge → lock', async ({ browser }) => {
const tag = runTag();
const digits = tag.replace(/\D/g, '').slice(-8).padStart(8, '0');
const code = `1590${digits.slice(-4)}`;
const today = new Date().toISOString().slice(0, 10);
const ref = `RCPT${digits}`;
// Setup through the API (the chart editor and manual journals have their own e2e):
// a fresh GL bank account, and a receipt of 500 booked to it with the reference in its description.
const owner = await api('owner');
// … POST /accounting/accounts {code, name: `Bank ${tag}`, type: 'asset', parent_id: <1500>, control_kind: 'bank'} …
// … POST /accounting/entries {journal_id: <BNK>, description: `Receipt ${ref}`, lines: [Dr bank 500, Cr 4100 500]} …
// 1. Finance links it.
const fin = await as(browser, 'finance');
await fin.goto('/banking');
await fin.getByRole('button', { name: 'Link bank account' }).first().click();
const dlg = fin.getByRole('dialog');
await dlg.getByRole('combobox', { name: 'Ledger account' }).click();
await fin.getByRole('option', { name: new RegExp(code) }).click();
await dlg.getByLabel('Account number').fill(`5140${digits}`);
await dlg.getByRole('button', { name: 'Save' }).click();
await fin.getByRole('row', { name: new RegExp(code) }).click();
// 2. Import: the receipt auto-matches by reference.
await fin.getByRole('button', { name: 'Import statement' }).click();
await fin.getByRole('dialog').locator('input[type=file]').setInputFiles({
name: `${tag}.sta`, mimeType: 'text/plain', buffer: Buffer.from(mt940(`5140${digits}`, today, ref)),
});
await fin.getByRole('dialog').getByRole('button', { name: 'Import statement' }).click();
await expect(fin.getByText('1 lines matched automatically')).toBeVisible();
// 3. The charge is posted to Bank charges from its line.
await fin.getByRole('tab', { name: 'Matching' }).click();
await fin.getByRole('row', { name: /SERVICE CHARGE/ }).getByRole('button', { name: 'Post to account' }).click();
const pj = fin.getByRole('dialog');
await pj.getByRole('combobox', { name: 'Account' }).click();
await fin.getByRole('option', { name: /6180/ }).click();
await expect(pj.getByLabel('Description')).toHaveValue('Bank charges');
await pj.getByRole('button', { name: 'Post' }).click();
// 4. Reconciliation: difference zero; Finance cannot lock, the owner can.
await fin.getByRole('tab', { name: 'Reconciliation' }).click();
await expect(fin.getByTestId('rec-difference')).toHaveText(/0\.00/);
await expect(fin.getByRole('button', { name: 'Lock reconciliation' })).toHaveCount(0);
const own = await as(browser, 'owner');
await own.goto('/banking');
await own.getByRole('row', { name: new RegExp(code) }).click();
await own.getByRole('tab', { name: 'Reconciliation' }).click();
await own.getByRole('button', { name: 'Lock reconciliation' }).click();
await own.getByRole('dialog').getByRole('button', { name: 'Lock reconciliation' }).click();
await expect(own.getByText(/Locked by/)).toBeVisible();
});

(Replace each // … with the calls; data-testid="rec-difference" is added to the difference amount in Task 12’s component if it is not already there — the one test id this flow needs.)

  • Step 3: Run it on lane 4.
Terminal window
STACK=lane4 PG_PORT=56452 MINIO_PORT=59140 API_PORT=3140 WEB_PORT=5195 \
E2E_API_PORT=3140 E2E_WEB_PORT=5195 \
pnpm -C web exec playwright test e2e/banking.spec.ts

Expected: PASS (first run creates the lane-4 stack and seeds it; the seed already grants Finance). Tear down afterwards: STACK=lane4 PG_PORT=56452 MINIO_PORT=59140 scripts/dev-stack.sh down. Never run against 3000/5173.

  • Step 4: Roadmap and deploy notes. docs/ROADMAP.md, Phase 2: tick the line and add under it:
- [x] Bank statement import (MT940 / CSV), auto-match, reconciliation
Design: [`2026-09-27-bank-reconciliation-design.md`](superpowers/specs/2026-09-27-bank-reconciliation-design.md) (D1–D16), migrations `00350`–`00353`
Plan: [`2026-09-27-bank-reconciliation.md`](superpowers/plans/2026-09-27-bank-reconciliation.md) (13 tasks, Rulings B1–B18)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — existing Owners/Finance gain `banking.*`; nothing to provision
- Reconciliation difference is provably only the go-live gap (property test in `internal/platform/banking`)
- End to end: link → import MT940 → auto-match → journal the charge → owner locks (`web/e2e/banking.spec.ts`)
- [ ] Open decisions (owner's call): the eleven lines of the spec's "For the owner to confirm"
- Deferred: reconciliation export (CSV/XLSX/PDF); unlock; rules that post journals automatically; bank feeds; a "bank reconciled" period-close check

docs/DEPLOY_NOTES.md — a new top section “Phase 2 bank reconciliation”: migrations 00350–00353 (four tables for statements and matches, two for locked reconciliations, all RLS-forced; statement files stored in Postgres, ≤ 5 MiB each, never deleted — size the database backups accordingly); existing Owners gain the six banking.* keys and Finance roles five (not banking.reconciliation.lock), with a perm_version bump so live sessions refresh once; nothing to provision: each organization links its bank accounts on the new Bank accounts screen after creating (or reusing) GL accounts of control kind bank.

  • Step 5: Full suite, once. go test -p 2 -count=1 ./..., pnpm -C web test --run, pnpm -C web typecheck, pnpm -C web lint, make generate && git status --porcelain (empty). Any failure outside banking is investigated, not skipped.

  • Step 6: Commit. git add -A && git commit -m "Bank rec Task 13: e2e on lane 4, seed, roadmap, deploy notes" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Tasks in the same wave touch disjoint files and may run in parallel worktrees; each wave starts after the previous one merges.

Wave Tasks Why this grouping
1 1 Pure parsers; everything else consumes CSVMapping and Parsed.
2 2 bank_account and the test world.
3 3 Statements (needs 1, 2).
4 4 Match schema (00352) — also creates the reconciliation tables Tasks 6–7 need.
5 5 ∥ 6 ∥ 7 automatch.go, journal.go, reconciliation.go and their tests are disjoint. Each adds its own MapError cases in its own case block of errors.go — expect a trivial merge there; each adds bankingtest helpers only in a new file named for the task. Task 6’s lock test inserts the bank_reconciliation row directly if Task 7 has not merged.
6 8 Module, permissions, constants.yaml, the first OpenAPI block, wiring.
7 9 Remaining routes (shares openapi.yaml and routes.go with 8).
8 10 Web hooks and translations every tab uses.
9 11 ∥ 12 Disjoint components on Task 10’s hooks.
10 13 E2E, docs, full suite.
Spec Where
D1 bank account on one GL account; currency derived; number Task 2 (B2, B3)
D2 book amount Task 4 bookAmountExpr, bank_match_valid; Task 7 queries
D3 immutable, hashed, chained statements; void; verbatim text; ISO-8859-1 Tasks 1, 3, 4 (B9)
D4 MT940 Task 1
D5 CSV mapping Tasks 1, 2, 3
D6 groups, one active match per line, append-only unmatch, cleared_on Task 4 (B7, B8)
D7 auto-match Task 5 (B6)
D8 opening clearance, Owner-only Tasks 4, 8, 9
D9 journal from a line through ledger.Post Task 6 (B13)
D10 permissions and seeding Task 8
D11 reconciliation statement and its invariant Task 7 (property test)
D12 lock and its effects Tasks 4 (guards), 6, 7
D13 no unlock nothing built (by design)
D14 dates and lock order Global Constraints
D15 no workflow kind nothing built (by design)
D16 no reconcile check nothing built (by design)
API table Tasks 8, 9
Web Tasks 10–12 (B17)
e2e Task 13

The migrations in Tasks 3–4 mark three mechanical repetitions with … (RLS blocks, the three reconciliation guards, the replaced void guard and Down); Task 4 Step 3 requires every one written out in full. Task 13’s e2e marks its API setup calls with // …, with the exact endpoints and bodies named. No other placeholders.

BankAccount.AccountID is always the GL account; bank_account_id always the bank account. Statement.EndsOn is the reconciliation date of its lines everywhere (B7). Kind*/Rule* constants match the SQL enums. No exported name collides with internal/platform/accounting/reconcile (B1).