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)
Global Constraints
Section titled “Global Constraints”Every task’s requirements implicitly include this section.
- Migrations
00350–00369only. This plan uses00350–00353. Task 2 registers{350, 369, "Phase 2 bank reconciliation"}ininternal/kernel/db/migrationranges_test.goin the same commit as00350. - Money never float.
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire (the OpenAPIMoneyschema). Compare withCmp, never==. Nofloat64/strconv.ParseFloatanywhere ininternal/platform/banking,internal/modules/bankingorweb/src/features/banking(web/src/drift/nofloat.test.tsscans the web). Web sums usesumMoney/compareMoneyfromweb/src/lib/money.ts. - Every new table has
org_id UUID NOT NULL REFERENCES organization (id),ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolationwithUSINGandWITH CHECKoncurrent_setting('app.current_org', true)::uuid, aCONSTRAINT <table>_tenant_id UNIQUE (org_id, id), and composite(org_id, …)foreign keys only (TestEveryTenantForeignKeyIsCompositeOnOrgID). - Named constraints. Every
CHECK/UNIQUE/FOREIGN KEYisCONSTRAINT <table>_<rule>; no name ends in_check,_keyor_excl(TestEveryConstraintIsNamed). A trigger’s refusal isRAISE EXCEPTION … USING ERRCODE = '23514', CONSTRAINT = '<name>'; Go maps on that name (banking.MapError). - Pinned functions. Every function is
SET search_path = pg_catalog, public, pg_tempand schema-qualifies every table (TestEveryFunctionPinsItsSearchPath). NoSECURITY 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 = $nfor each org-scoped table it touches, join equalities included, and everyINSERTnamesorg_id(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). Platform functions read the org fromtenancy.OrgFromContext; nothing takes an org argument. - Retention. Every new table is in exactly one list of
internal/kernel/db/retention_test.go:bank_account,bank_statementareamendable(REVOKE DELETE);bank_statement_line,bank_match,bank_match_item,bank_unmatch,bank_reconciliation,bank_reconciliation_itemareappendOnly(REVOKE UPDATE, DELETE). - The ledger is append-only and modules never write lines. The only ledger write in this plan is
ledger.Postininternal/platform/banking/journal.gowithSemantic: ledger.SemanticManual(spec D9). No SQL in this plan inserts intojournal_*. - Accounting reacts to semantics. Nothing here compares a workflow
state_key; banking readsjournal_linegenerically byaccount_id, never a module’s document tables. - Lock order, one for the whole plan (spec D14):
bank_accountrowFOR UPDATE(vialockBankAccount) →ledger.Post(period, sequence, its audit) → banking rows → banking’saudit.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 passesdb.InTx’s error throughbanking.MapError(which falls through toledger.MapError) before mapping it, because the balance/group/total checks are deferred triggers that fire at COMMIT. - Business date is
clock.TodayUTC(c); everyDATEhandled in Go is midnight UTC; nevertime.Now()in code under test. - Record-bearing text the business writes (void reason, unmatch reason, journal description) is English or Bahasa Malaysia:
recordtext.Checkin Go and a namedCHECK (… !~ '[-ヿ㐀-䶿一-鿿가-]')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 tobank_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 detailfield=<name> rule=<code>: <sentence>(banking.FieldError, thepayables.FieldErrorshape). - Every route goes through
httpx.Protectedwith abanking.*permission and is documented inapi/openapi.yamlin the same commit (TestTheOpenAPIDocumentSaysWhatTheRouterEnforces).api/public_routes.txtgains nothing.make generateafter every OpenAPI orconstants.yamlchange; generated files are committed andgit statusis 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.tsrequires identical keys and placeholders in all three. Malay is genuine Malay (use the strings given in Task 10), never English copied intoms. - 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(throughexpensestest.New, viabankingtest.New) per test; run only the touched packages while iterating (go test ./internal/platform/banking/... -count=1); one fullgo 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, MinIO59140, API3140, Vite5195— never the user’s dev stack (3000/5173/55432/59000). - Git commits end with:
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rulings
Section titled “Rulings”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 likepayablestest) andinternal/modules/banking. Exported types areBankAccount,Statement,StatementLine,BookLine,Match,Reconciliation— neverCheck,ResultorRun, which arereconcile’s. - B2 Ruling: A bank account’s
currencyis 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 postablebankaccount (422field=account_id rule=bank_account) or already linked (409already_linked). - B3 Ruling:
account_numberis normalised in Go (spaces and-removed) and must then match^[0-9]{4,34}$(422field=account_number rule=digits). - B4 Ruling: Statement lines are inserted with one
INSERT … SELECT … FROM unnest($arrays)statement, notCOPY—COPY FROMis 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_nonzerobacks it. A zero line moves no balance and could never be in a balanced group with a non-zero book line. - B6 Ruling:
Importruns auto-match inside its own transaction (after inserting the lines, before its audit row) through the unexportedautoMatch, 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_onis its statement’sends_on; its ownbooked_onis used only by the auto-matcher’s date window (spec D6, D11). - B8 Ruling:
bank_match.cleared_onis computed in Go and verified by the deferred triggerbank_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
00351checking only “latest non-voided”;00352replaces the function (CREATE OR REPLACE) adding “no active match” and “after the latest lock”, becausebank_matchandbank_reconciliationdo not exist before00352. Its Down restores the00351body. - B10 Ruling: Book lines offered for matching are every
journal_lineon 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-linestakesfrom,to(dates, default the last 90 days to today) andunmatched(bool, default true); capped at 2000 rows, ordered by entry date then entry number;truncated: truewhen capped. - B12 Ruling:
GET /banking/statements/{id}returns every line withmatch({id, kind, rule, cleared_on}of its active match, or null) andmatch_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
descriptionis required (422field=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}/reconciliationsreturns the lockedReconciliation(201); the live GET returnslockednon-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_ofnot equal to one is 422field=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
accountinggroup gains one item, Bank accounts →/banking(banking.read); the account page is/banking/$idwith tabsstatements | matching | reconciliationin the search paramtab. - B18 Ruling:
scripts/dev-stack.shreadsPG_PORT,MINIO_PORT,API_PORT,WEB_PORTandSTACK(container-name suffix and state dir) from the environment with today’s values as defaults, andweb/playwright.config.tsreadsE2E_WEB_PORT/E2E_API_PORT; if another Phase 2 item has already done this, reuse theirs and skip that step.web/vite.config.tsreadsAPI_PORTfor its proxy target (default 3000).
File Structure
Section titled “File Structure”Migrations (db/migrations/)
Section titled “Migrations (db/migrations/)”| 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. |
New Go packages
Section titled “New Go packages”| 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. |
Modified (shared)
Section titled “Modified (shared)”| 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 fixturesinternal/platform/banking/testdata/{two_messages.sta,wrapped.sta,latin1.csv}
Interfaces:
- Consumes:
money.Amount,money.Parse. - Produces:
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.gofunc decodeText(b []byte) string // valid UTF-8 as is (BOM stripped), else ISO-8859-1 byte→rune
// mapping.gotype 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 outputstype 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 = 10000func ParseMT940(b []byte) (Parsed, error)func ParseCSV(b []byte, m CSVMapping) (Parsed, error)- Step 1: Failing tests.
mt940_test.go(packagebanking, internal — the parsers are pure):
const twoMessages = `:20:STMT1:25:MBBEMYKL/514012345678:28C:00001/001:60F:C260901MYR1000,00:61:2609020902D250,00NTRFSP-2026-0001//BR123PAYMENT TO SUPPLIER:86:IBG ACME SDN BHD SP-2026-0001REF 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
ParseMT940treatRDas negative;TestParseMT940TwoMessagesChainIntoOneStatementfails atRD is a reversal of a debit; restore. Remove the running-balance comparison;TestParseCSVRefusesAWrongRunningBalancefails at itsParseErrorassertion; 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 batchesfunc ListBankAccounts(ctx context.Context, tx pgx.Tx) ([]BankAccount, error) // by account codefunc 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
// bankingtesttype Env struct{ *expensestest.Env }func New(t *testing.T) *Envfunc (e *Env) As(member uuid.UUID) context.Contextfunc (e *Env) GLBank(t *testing.T, code string, currency *string) uuid.UUID // a new postable bank-control asset account under 1500func (e *Env) BankAccount(t *testing.T, glCode, number string) banking.BankAccount // GLBank + CreateBankAccount by Staff, window 3, rule 2 onfunc (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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER bank_account_guard BEFORE INSERT OR UPDATE ON bank_account FOR EACH ROW EXECUTE FUNCTION bank_account_guard();
-- +goose DownDROP 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.gogains{350, 369, "Phase 2 bank reconciliation"};retention_test.go’samendablegains"bank_account"under a// Bank reconciliation (00350–00353)comment. -
Step 5: Run passing.
go test ./internal/platform/banking/... ./internal/kernel/db/... -count=1→ PASS (includingTestEveryMigrationIsNumberedInItsOwnersRange, 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);TestBankAccountGuardRefusesANonBankGLAccountfails at the USD case; restore. Delete"bank_account"fromamendable; the retention test fails namingbank_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 << 20type 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_filetype OpeningMismatchError struct{ Expected money.Amount } // 422 field=opening_balance rule=chainvar ( 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 DESCfunc 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
// bankingtestfunc MT940(account, currency string, opening string, openingOn string, lines []MTLine, closingOn string) []bytetype MTLine struct{ Date, Amount, Ref, Details string } // Amount signed, "−" → Dfunc (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;StatementFilereturns the bytes;file_sha256=sha256.Sum256hex.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) →*DuplicateFileErrortoo (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 whoseends_onequals statement 2’s →ErrNotAfterPrevious.TestImportChecksTheMT940Account—:25:MBBEMYKL/514012345678into bank account number514012345678imports; into999912345678→FieldError{Field: "file", Rule: "account_mismatch"}; currencyUSDinto 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 needsOpeningandClosing(FieldErroropening_balance/closing_balancerequired), a later one defaultsOpeningto the previous closing; aClosingthat does not equal opening + Σ →FieldError{Field: "closing_balance", Rule: "unbalanced"};StartsOn/EndsOnrequired,StartsOn > EndsOnrefused; 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— onebank_statementimportaudit row whoseafterhas nofile_byteskey.
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 toclosing − openingfails at COMMIT withbank_statement_balanced; aline_countthat 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 wrongfile_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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER bank_statement_guard BEFORE INSERT OR UPDATE ON bank_statement FOR EACH ROW EXECUTE FUNCTION bank_statement_guard();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndCREATE 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 DownDROP 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):
acct, err := lockBankAccount(ctx, tx, in.BankAccountID);!acct.IsActive→ErrInactive.1 ≤ len(in.File) ≤ MaxFileBytes(file/size); file name trimmed, 1–255 characters (file_name/required).sum := sha256.Sum256(in.File); an existing non-voided statement with that hash →*DuplicateFileError.- Parse by
in.Format(format/knownotherwise). MT940:digits(p.Account)must containacct.AccountNumber(account_mismatch);p.Currency == acct.Currency(currency_mismatch); starts/ends/opening/closing fromp. CSV:acct.CSVMapping == nil→ErrNoCSVMapping;StartsOn/EndsOnrequired; balances per spec D5 (a mapped balance column wins; an input balance that disagrees with it →opening_balance/closing_balancerule=balance_column). prev := latestStatement(…): when present,ends ≤ prev.EndsOn→ErrNotAfterPrevious;opening ≠ prev.Closing→*OpeningMismatchError{Expected: prev.Closing}; for CSV withOpening == nil,opening = prev.Closing. When absent and no opening →opening_balance/required.opening + Σ lines ≠ closing→closing_balance/unbalanced(MT940’s own check already ran; this is the CSV one).- Insert the statement (
id = uuid.New(),csv_mapping= the account’s mapping for CSV) and its lines in oneINSERT … SELECT … FROM unnest($3::int[], $4::date[], $5::date[], $6::numeric[], $7::text[], $8::text[], $9::text[], $10::text[])(B4; amounts passed as[]stringfromAmount.String()). ids, err := autoMatch(ctx, tx, c, acct, in.By)(Task 5 fills it in; this task addsfunc autoMatch(...) ([]uuid.UUID, error) { return nil, nil }inautomatch.go).audit.Write(bank_statement,import,After: theStatementplus"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_balancefrom the guard;TestStatementChainInTheDatabasefails; restore. Makebank_statement_line_guardRETURN NEWunconditionally;TestStatementLinesCannotBeAddedLaterfails; restore. Remove step 3 in Go; the cross-account CSV case ofTestImportRefusesTheSameFileTwicestill gets*DuplicateFileError, now frombank_statement_file_oncethroughMapError(the index is the backstop; the same-account case is answered by the chain check first,ErrNotAfterPrevious— record that too); then also comment out thatCREATE UNIQUE INDEXand 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(MapErrornames),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=unbalancedvar ( 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 lockfunc 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_reconciliationfunc 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’sends_onwhen that is later than the entry date); both lines then showMatchID.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 itsledger.Reverse(Σ = 0) match with no statement line.TestALineIsInOneActiveMatch— presence: line L matches; a second group naming L →ErrLineTaken; afterUnmatch(reason "Salah padanan")it matches again;LineHistoryshows both groups, the first withUnmatchedAt.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 (kindopening, 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_balancedat COMMIT,bank_match_valid(a wrongcleared_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 StatementBeginCREATE 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 StatementBeginCREATE 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 StatementEndCREATE TRIGGER bank_match_guard BEFORE INSERT ON bank_match FOR EACH ROW EXECUTE FUNCTION bank_match_guard();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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 StatementBeginCREATE 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 StatementEndCREATE CONSTRAINT TRIGGER bank_match_valid AFTER INSERT ON bank_match DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION bank_match_valid();
-- +goose StatementBeginCREATE 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 StatementEndCREATE 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)frombank_match_item_guard;TestALineIsInOneActiveMatchfails at the re-match after unmatch (the DB now refuses a line that was only ever in an unmatched group); restore. RemoveOR v_ss <> v_sb; the schema test forbank_match_balancedfails; restore. Remove the GoErrLineTakencheck;TestALineIsInOneActiveMatchstill passes throughMapError(backstop proven) — record that — then also removebank_match_item_line_free’sIFand 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>"
Task 5: Auto-match
Section titled “Task 5: Auto-match”Files:
- Modify:
internal/platform/banking/automatch.go(replaces Task 3’s stub) - Test:
internal/platform/banking/automatch_test.go(pure, packagebanking),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) []Pairfunc 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 memoSP-2026-0001 BR123; a statement line of −250 whose description hasSP20260001→ paired byreferencewith that one, the other left.TestAmountDateRuleNeedsMutualUniqueness— one statement line 100 and two book lines 100 in the window → no pair; remove one → pairedamount_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— bookSP-2026-0001−250 and an unrelated −99.90, thenImportan MT940 with −250 (refSP-2026-0001) and −12.50 →AutoMatched = 1, the match iskind auto, rule reference;RunAutoMatchagain → 0 (presence of the earlier match, nothing new); a book line posted later for −12.50 thenRunAutoMatch→ 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;TestAmountDateRuleNeedsMutualUniquenessfails 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 4insertMatch,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_accountfunc 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.50SERVICE CHARGEand +3.21INTEREST):TestPostFromLineBooksACharge— account6180, description"Caj bank September"→ an entry withsemantic = manual,source_kind = bank_statement_line,source_id= the line,posted_by= Staff, dated the line’sbooked_on,Dr 6180 12.50 / Cr <bank GL> 12.50, on the journal whose default account is the GL (orBNK); the line is now in ajournalmatch 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: countjournal_entry WHERE source_id = lineis 0 after the refused call’s rollback);AccountID= the bank GL →ErrOwnAccount;AccountID= Trade payables2105→ the ledger’s*ledger.ControlAccountManualError; a Chinese description →recordtextfield error; blank →description/required; a line of a voided statement →ErrVoided.TestPostFromLineRespectsALock— after a lock at the statement’sends_on(use Task 7’sLockonce it exists; until then insert thebank_reconciliationrow as the admin in the test withdifference 0), the default date →ErrLocked;EntryDate=ends_on + 1succeeds.
-
Step 2: Run — expect failure.
-
Step 3: Implement. In order: read the line (
bank_account_id,booked_on,amount, statementvoided_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.BookedOnor*in.EntryDate(midnight UTC);locked != nil && !date.After(*locked)→ErrLocked;journalID, journalType := journalFor(ctx, tx, acct.AccountID)(copied frompayables.journalFor, with the same comment); lines:
amt := line.Amountbank := 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.IDentry, 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, atinsertMatchor COMMIT throughbank_match_item_line_free→ErrLineTaken, and its “no journal entry” count still holds because the whole transaction rolls back — record that; restore. PassKindManualinstead ofKindJournaltoinsertMatch; the charge test’skind journalassertion 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>"
Task 7: Reconciliation statement and lock
Section titled “Task 7: Reconciliation statement and lock”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) andLock→ErrDifference.TestLockFreezes— after the lock at 09-30:Reconcile(09-30)returnsLockednon-nil and the stored items; posting another book line dated 09-29 changes a fresh recomputation (checked by calling the unexportedcompute) but not the locked result;Unmatchof the −250 match (cleared_on09-30) →ErrLocked;VoidStatementof the 09-30 statement →ErrVoidNotAllowed;PostFromLineon 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’sends_on→ErrNoStatementOnDatefor bothReconcileandLock.TestLockIsStoredAndAudited— onebank_reconciliation,item_count = 4, onelockaudit 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 withorg_id = $1on every table):
-- BSELECT 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 DSELECT 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 butTestLockFreezes’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 GoErrDifferencecheck;TestLockRefusedWithADifferencestill refuses viabank_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: copyinternal/modules/payables/env_test.go’s harness (newEnv,grant,mint,do,decode) withbx *bankingtest.Envandbanking.Register(api, banking.Deps{DB: bx.DB, Clock: bx.Clock}); membersclerk= Staff granted every key exceptPermissionReconciliationLockplusaccounting.ledger.read,owner= Owner granted all six,readergrantedPermissionReadonly.account_handlers_test.go: create (201,ETag: "1",account_labelis the translated GL name,currencyderived), list, get, put withIf-Match(200 →"2"; stale → 409 withrevision; missing → 400), 422field=account_id rule=bank_account, 409code=already_linked, the reader’s POST → 403.routes_test.go: every route’s permission pinned (the payablesroutes_test.gotable form).grants_test.go: a newly provisioned org’s Finance role has the five keys and notbanking.reconciliation.lock(presence first: it hasbanking.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 theperm_versionbump (themigration00305_test.goshape). -
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 statusclean aftermake generate. -
Step 5: Sabotage. Add
PermissionReconciliationLockto the Finance registration;grants_test.gofails at its absence assertion; restore. Removebanking.Registerfromapp.Router;TestEveryModuleContributesRoutesAndPermissionsfails namingbankingand 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 withstatementandauto_matched; the same body → 409code=duplicate_filewithstatement_id; bad base64 → 400; > 7 MiB body → 400field=file rule=size(apperrhas no too-large kind; the handler turns*http.MaxBytesErrorfrom the decoder into that 400); a broken:61:→ 422 detail startingfield=file rule=mt940_syntax: line; the file download returns the exact bytes withContent-Type: application/octet-streamandContent-Disposition: attachment; filename="sep.sta"; void → 200 and the statement showsvoided_at; the reader’s import → 403.match_handlers_test.go: book-lines list (B11 defaults;truncated), manual match 201 / 422rule=unbalanced/ 409code=line_taken, unmatch 200 / 409code=reconciliation_locked, auto-match 200{"matched": n}, opening clearance by the clerk → 403 and by the owner → 201, journal 201 withentryandmatch, journal onto2105→ the ledger’s 422/409 exactly asPOST /accounting/entriesanswers 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 →lockednon-null, list → one row; a lock with a difference → 409code=difference_not_zero;as_ofnot a statement date → 422field=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 mapsbanking.MapError(err)thenmapErr. 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 statusclean aftermake generate. -
Step 5: Sabotage. Register
/banking/accounts/:id/opening-clearanceunderPermissionMatch;routes_test.gofails 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.tstypes (Schemas['BankAccount']…),api,unwrap,DataTable(tableId),Dialog,Combobox,Field,Input,Checkbox,Select,RequirePermission,useAccountsfromweb/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: aFileof bytes[0, 255, 1, 2]and one of 100 000 random bytes round-trip throughatob.BankAccountsPage.test.tsx(MSW): the list rendersaccount_code — account_labeland the masked-free number; the New dialog lists only GL accounts withcontrol_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 422field=account_numbershows 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: spreadbankingRoutes.routes.tsx:/banking(list,RequirePermission banking.read),/banking/newand/banking/$id/edit(dialog children),/banking/$id(account page shell withtabsearch param,z.enum(['statements','matching','reconciliation']).catch('statements'); Tasks 11–12 fill the tabs — this task renders theTabswith 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) andpnpm -C web typecheck→ PASS. -
Step 5: Sabotage. Delete
matching.mustAgreefromms;i18nparity.test.tsfails 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;RecordLanguageHintfromweb/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,Voidedchip; Import with an.stafile postsformat: 'mt940'and base64 content, then toastsstatements.autoMatched; a.csvfile switches to CSV and shows from/to (defaulting empty) and opening/closing fields; a 409duplicate_fileshowsstatements.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 showsmatching.mustAgreeand Match stays disabled (the comparison iscompareMoney, no float); Auto-match posts and invalidates; “Post to account” on a −12.50 line pre-fillsBank 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 withoutbanking.reconciliation.lock. -
Step 2: Run — expect failure.
-
Step 3: Implement. Two
DataTables side by side (stacked below 768 px;tableIdbanking.statementLines/banking.bookLines), each with row checkboxes, a date filter on the ledger side (defaults: B11), and a sticky footer withmatching.selectedTotal. The statement-line table shows only lines withmatch == nullby 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), andRecordLanguageHintsits 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) === 0withNumber(a) === Number(b);nofloat.test.tsfails 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);Differencenon-zero showsrec.differenceHintand disables Lock; with a zero difference andbanking.reconciliation.lock, Lock opensrec.lockConfirmand posts{as_of}; a locked date showsrec.lockedand 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 printhides the shell — the existingprint:hiddenutility 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,runTagfromweb/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 nameserp-$STACK-pg/erp-$STACK-minio; state dir.dev-stackwhenSTACK=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’sAPI_BASEtoo.web/vite.config.tsproxies tohttp://localhost:${process.env.API_PORT ?? 3000}.web/playwright.config.tsusesE2E_WEB_PORT(default 5173) forbaseURL/the vite command andE2E_API_PORT(default 3000) for the health URL, and passes the lane variables through to the script. Defaults unchanged, sopnpm -C web e2ewithout 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.
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.tsExpected: 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 checkdocs/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 outsidebankingis 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. |
Self-Review
Section titled “Self-Review”Spec coverage
Section titled “Spec coverage”| 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 |
Placeholder scan
Section titled “Placeholder scan”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.
Type and name consistency
Section titled “Type and name consistency”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).