Skip to content

Bank statement import and reconciliation (Phase 2): design

Status: decided by the spec author under the owner’s “complete phase 2” instruction (decisions D1–D16 below); the compliance-sensitive ones are repeated in “For the owner to confirm”. Roadmap: Phase 2, “Bank statement import (MT940 / CSV), auto-match, reconciliation”. Architecture: §10.1 (control kind bank, many per org), §10.2 (journals, append-only ledger), §10.3 (modules never write lines), §10.4 (periods), §11A.2 / §11A.4 (retention, traceability), §19. Migrations: 00350–00369 (this item uses 00350–00353). Plan: docs/superpowers/plans/2026-09-27-bank-reconciliation.md.

internal/platform/accounting/reconcile and GET /v1/accounting/reconciliation are the §10.6 integrity checks (float integrity, trial balance, orphans). This item is bank reconciliation and never shares a name with it: Go package internal/platform/banking + module internal/modules/banking, tables bank_*, routes /v1/banking/…, permissions banking.*, web feature features/banking, i18n namespace banking. Nothing here registers a reconcile check (D16).

In: bank accounts (one per GL account of control kind bank), statement import from MT940 and from CSV with a per-account column mapping, statement lines stored immutably with the file and its SHA-256 (the same file cannot be imported twice), auto-match against ledger lines on that GL account (amount + date window + reference), manual match and unmatch with append-only history, opening clearance for go-live, a journal for bank charges / interest posted from an unmatched line through ledger.Post, and a reconciliation statement per statement date that can be locked.

Out (each its own item or later):

  • Export of the reconciliation statement to CSV/XLSX/PDF — the locked snapshot is stored and shown on screen; it joins the export pipeline with the statutory export (§11A.2). Reason: report_export parameters are period/as-of/scope, not “reconciliation id”, so it is a pipeline change, and AR is editing report_export_report_known in parallel.
  • Bank feeds / APIs (DuitNow, open banking), CAMT.053, OFX, BAI2 — MT940 and CSV only.
  • Rules that create journals automatically (e.g. “every line with NCHG → 6180”) — v1 journals are posted by a person from a line.
  • Foreign-currency revaluation of bank balances (the FX item of §10.5 is realised-only in v1 too).
  • Unlocking a locked reconciliation (D13).
  • A period-close precondition “bank reconciled” (D16).
  • Payment-batch bank files (their own item; they may read bank_account.account_number, see Dependencies).

D1. A bank account is a role on one GL account

Section titled “D1. A bank account is a role on one GL account”

bank_account (org_id, id, account_id UNIQUE, currency, account_number, match_window_days, match_on_amount_date, csv_mapping, is_active, revision, created_by, …).

  • account_id names an existing active, postable account with control_kind = 'bank' (the chart already allows many: account_one_control_per_kind excludes bank, 00050). One bank account per GL account (UNIQUE (org_id, account_id)). Linking only: the GL account is created in the chart editor first. Reason: the chart already owns account creation, names and translations; a second creation path would duplicate it.
  • No name column. The display name is the GL account’s code and translated name (the “every user-facing name is a translation row” invariant).
  • currency is the statement currency. If the GL account has a currency it must equal it; if the GL account’s currency is null (any), currency must be the base currency. Enforced in Go and by the insert trigger bank_account_gl_is_bank. account_id and currency are fixed once created (bank_account_identity_fixed).
  • account_number is digits only (spaces and dashes stripped on input), 4–34 digits; used to check an MT940 file’s :25: (D4). It is the company’s own account — not personal data — so it is stored and shown in full.

D2. The book amount of a ledger line, in the bank’s currency

Section titled “D2. The book amount of a ledger line, in the bank’s currency”

For every rule below, a journal line on the bank account’s GL account has a signed book amount: base_debit − base_credit when the bank account’s currency is the base currency, else debit − credit (the GL account is then restricted to that currency, so every line is in it). Positive is money in. A statement line’s amount is signed the same way (credit on the bank’s side = money in = positive). Matching and reconciliation compare only these two numbers.

D3. A statement is one import: immutable, hashed, chained

Section titled “D3. A statement is one import: immutable, hashed, chained”

bank_statement (…, format, starts_on, ends_on, currency, opening_balance, closing_balance, line_count, file_name, file_sha256, file_bytes BYTEA, csv_mapping (snapshot), imported_by/at, voided_at/by, void_reason) and bank_statement_line (…, statement_id, bank_account_id, line_no, booked_on, value_on, amount ≠ 0, description, reference, bank_reference, transaction_code).

  • The file itself is kept (file_bytes, ≤ 5 MiB) with its SHA-256, so the lines can always be shown to be what the bank sent (§11A.4). In Postgres rather than the object store: documents verifies images/PDF by magic bytes only, statement files are small text, and a BYTEA column needs no new store path. GET /banking/statements/{id}/file returns it.
  • Same file refused: partial unique index on (org_id, file_sha256) WHERE voided_at IS NULL; 409 names the existing statement. Per organization, not per account: one file is one account’s.
  • Internal balance: opening + Σ lines = closing, checked in Go and by a deferred constraint trigger at commit. Lines may be inserted only by the transaction that inserted their statement (imported_txid, the ledger’s posted_txid pattern), so nothing is appended to a statement later.
  • Chain: for a bank account, a new statement’s ends_on must be after the latest non-voided statement’s ends_on, and its opening_balance must equal that statement’s closing_balance. The first statement’s opening is free. This is the real duplicate/overlap guard (a re-export of the same period with different bytes fails the chain) and it is what makes D11’s balance identity hold. Out-of-order import is refused: import the missing statement first.
  • Void, not delete: only the latest non-voided statement of an account, with no active match on any of its lines, and ends_on after the latest locked reconciliation, may be voided with a reason (voided_* set once; a guard refuses any other UPDATE). Its lines stay; they drop out of matching and reconciliation. The same bytes may then be imported again (e.g. with a corrected CSV mapping).
  • Statement text (description, references) is stored verbatim, not checked against the en/ms record-language rule: it is a copy of a third-party document, like an uploaded receipt, and a payer’s name in Chinese is not the business’s narration. Every narration the business writes here (journal description, void and unmatch reasons) is en/ms-checked. (Owner to confirm.)
  • Text that is not valid UTF-8 is decoded as ISO-8859-1 (stdlib, byte → rune); the bytes are kept as received either way.

Hand-written parser, stdlib only (internal/platform/banking/mt940.go). Accepts an optional SWIFT block wrapper ({1:…}{2:…}{4: … -}) and several messages in one file (:20: … :62F:/:62M:). Tags used: :25: account, :60F:/:60M: opening, :61: line (+ its optional second line), :86: details (multi-line, up to the next :NN: tag), :62F:/:62M: closing. :20:, :28C:, :64:, :65: are read and ignored.

  • :61: = value date YYMMDD, optional entry date MMDD (year inferred from the value date, nearest within ±6 months), mark D / C / RD / RC (C and RD positive), optional funds-code letter, amount with decimal comma, type (N/F/S + 3), customer reference up to //, bank reference after //, optional supplementary line. booked_on = entry date when present, else value date.
  • All messages in one file must name the same :25: and currency; message n’s opening equals message n−1’s closing; each message balances. starts_on = the first opening date, ends_on = the last closing date, opening = first opening, closing = last closing.
  • :25: must contain the bank account’s account_number as a digit substring (422 account_mismatch) and the currency must be the bank account’s (422 currency_mismatch).
  • YY → 20YY. Anything unparseable is 422 field=file rule=mt940_syntax: line N: ….
  • Unsure: no real Malaysian bank MT940 sample is in the repository; how Maybank, CIMB, Public Bank, RHB and Hong Leong fill :25: and :86: must be tried with a customer’s file (owner item).

bank_account.csv_mapping (JSONB, validated in Go; snapshotted onto each CSV statement): delimiter (, ; \t |), header_rows, footer_rows (0–20 each), date_column, date_format (closed list: DD/MM/YYYY, DD-MM-YYYY, DD.MM.YYYY, DD/MM/YY, YYYY-MM-DD, DD MMM YYYY, MM/DD/YYYY), value_date_column (0 = none), description_columns (joined by a space), reference_column (0 = none), either amount_column (signed) or debit_column + credit_column, decimal_comma, balance_column (0 = none), newest_first. Columns are 1-based indexes (header names differ between banks and exports; an index is unambiguous).

  • Amount cells: strip spaces, RM, the thousands separator; (x), leading or trailing - is negative; debit/credit mode needs exactly one non-empty cell per row. Blank rows are skipped.
  • The request carries starts_on, ends_on (the UI defaults them to the first and last line dates), and balances: with a balance_column, opening and closing are derived and every row’s running balance is verified (422 running_balance naming the row); without one, closing_balance is required (typed from the bank’s statement) and opening_balance defaults to the previous statement’s closing (required for the first statement). Either way D3’s checks apply.
  • Parser: encoding/csv with LazyQuotes, FieldsPerRecord = -1; a UTF-8 BOM is stripped.
  • At most 10 000 lines per statement.

D6. Matches are groups; history is append-only

Section titled “D6. Matches are groups; history is append-only”

bank_match (id, bank_account_id, kind, rule, cleared_on, matched_by, matched_at), bank_match_item (match_id, statement_line_id | journal_line_id), bank_unmatch (match_id UNIQUE, reason, unmatched_by, unmatched_at). All three are insert-only (no UPDATE/DELETE grant). A match is active until a bank_unmatch row names it. Unmatching never touches the match; re-matching inserts a new one. The history of every line is the list of groups it has been in.

  • kind ∈ auto (with rule ∈ reference | amount_date), manual, journal (D9), opening (D8).
  • A group other than opening has at least two items and Σ statement amounts = Σ book amounts. That allows 1:1, one deposit ↔ many receipts, one bulk debit ↔ many payments, and a contra group of book lines only (an entry and its reversal, Σ = 0) or statement lines only (a bank error and its correction). Matching against ledger lines is generic — any source (claims, AP, AR, advances, manual) — because it reads journal_line on the GL account, never a module’s tables.
  • A line is in at most one active match: an insert trigger on bank_match_item takes the bank account row FOR UPDATE (serializing every match/unmatch/lock on the account) and refuses a second active membership (bank_match_item_line_free). journal_line cannot be row-locked by the app role (no UPDATE grant), which is why the lock is the account row. ponytail: one lock per bank account; fine at bank-statement volumes.
  • cleared_on = the latest item date, where a statement line’s date is its statement’s ends_on and a book line’s date is its entry date. Stored at insert, verified by the deferred trigger bank_match_valid. D11 uses it.
  • Unmatch needs a reason (en/ms).

D7. Auto-match: two rules, mutual uniqueness, never a guess

Section titled “D7. Auto-match: two rules, mutual uniqueness, never a guess”

Pure function AutoMatch(statementLines, bookLines, windowDays, amountDateOn) []Pair over unmatched lines of the account, 1:1 only:

  1. reference — equal amounts, |booked_on − entry_date| ≤ window, and a reference token of the book line appears in the statement line’s reference + bank_reference + description. Tokens come from the entry number, the entry description and the line memo (where AP puts SP-… plus the bank reference, and every module puts its document reference): split on anything but letters, digits and -, upper-cased, keep those with ≥ 5 letters/digits including a digit; compared with non-alphanumerics stripped on both sides (banks drop hyphens).
  2. amount_date (when match_on_amount_date) — equal amounts and within the window.

A pair is accepted only when, under that rule, the statement line has exactly one candidate and that book line has exactly one candidate. Rule 1 runs first; accepted lines leave the pool; then rule 2. Deterministic and order-independent; an ambiguous line stays for a person. Auto-match writes matches directly (kind = auto, the rule recorded); a wrong one is unmatched. It runs inside the import transaction and on demand (POST /banking/accounts/{id}/auto-match). Per account: match_window_days (0–31, default 3) and match_on_amount_date (default true).

An organization that already has ledger history on the GL account before its first imported statement has book lines the bank reflected long ago; they would sit as “outstanding” forever. An opening group clears book lines only, each dated before the account’s first non-voided statement’s starts_on, with no statement line and no balance requirement; cleared_on is the latest of them. Its own route and permission (banking.reconciliation.lock, Owner by default), because clearing without bank evidence is a sign-off, not bookkeeping.

D9. A journal from an unmatched line (charges, interest, transfers)

Section titled “D9. A journal from an unmatched line (charges, interest, transfers)”

POST /banking/statement-lines/{id}/journal {account_id, description, entry_date?, distribution?}: one transaction — lock the bank account; the line must be unmatched and its statement not voided; ledger.Post with Semantic = manual, SourceKind = "bank_statement_line", SourceID = line id, PostedBy = caller, the bank account’s journal (the active journal whose default account is the GL account, else the default bank journal — payables.journalFor, copied), currency = the bank account’s, two lines: the GL bank account for the line’s amount (debit when positive) and account_id for the opposite side; then a journal match of the statement line with the new bank line; then the audit row.

  • This is how manual journals are posted today (internal/modules/accounting/ledger_handlers.go postJournalEntry → ledger.Post, the one writer), so the ledger’s own rules apply unchanged: open period, postable active account, no sub-ledger control or module-owned account on a manual line (so a customer receipt cannot be booked here past AR, nor a supplier payment past AP), analytic distributions, record-language description. The dispatch’s “through the posting engine” is satisfied by ledger.Post; the Poster (accounting.Poster) only runs on workflow transitions and there is no workflow here (D15).
  • entry_date defaults to the line’s booked_on; if that is on or before the latest locked reconciliation the request is 409 reconciliation_locked and the caller passes a later date.
  • account_id may not be the bank account’s own GL account (422). No tax split in v1: SST charged by a bank is cost, the whole amount goes to the chosen account.
  • The DB checks a journal group is exactly one statement line and one book line whose entry has source_kind = 'bank_statement_line' and source_id = that statement line.
  • Reversing that entry later through /accounting/entries/{id}/reversal is not blocked (accounting does not know banking): the reversal line is simply a new outstanding book line; the user unmatches and contra-matches. Recorded, not guarded.
Key Grants Seeded
banking.read bank accounts, statements, files, lines, matches, reconciliations Owner, Finance
banking.account.manage link / edit bank accounts and CSV mappings Owner, Finance
banking.statement.import import and void statements Owner, Finance
banking.match auto-match, manual match, unmatch Owner, Finance
banking.journal.post post a journal from a statement line Owner, Finance
banking.reconciliation.lock lock a reconciliation, opening clearance Owner

New organizations: Owner receives every registered key (provisioning); Finance through provisioning.RegisterRolePermissions. Existing organizations: migration 00353 (the 00305 pattern) plus perm_version bump. The bank screens also read /accounting/accounts (accounting.ledger.read, which Finance already has from 00305).

For a bank account and a date D that is a non-voided statement’s ends_on:

  • S = that statement’s closing balance. B = Σ book amounts of lines on the GL account dated ≤ D.
  • An item is reconciled as of D when it is in an active match with cleared_on ≤ D.
  • Outstanding book items U_b = book lines dated ≤ D not reconciled as of D (deposits in transit are positive, unpresented payments negative).
  • Unrecorded statement items U_s = lines of non-voided statements with ends_on ≤ D not reconciled as of D (charges and receipts not yet in the books).
  • adjusted = S − ΣU_s + ΣU_b; difference = adjusted − B.

Because every non-opening group balances and the chain makes S = first opening + Σ statement lines up to D, difference = first statement's opening balance − Σ opening-cleared book lines — independent of matching. So a non-zero difference always means go-live clearance is incomplete (or wrong), never “matching isn’t finished”; matching only moves amounts between the item lists and the reconciled total. A property test holds this.

GET /banking/accounts/{id}/reconciliation?as_of=D computes it live, or returns the locked snapshot when D is locked.

POST /banking/accounts/{id}/reconciliations {as_of} (under banking.reconciliation.lock): refused unless D is a non-voided statement’s ends_on, D is after the account’s latest locked date, and difference = 0. It inserts bank_reconciliation (the five totals; CHECKs that they add up and that the difference is zero) and bank_reconciliation_item rows (each outstanding and unrecorded item with its amount) — only locked reconciliations are stored, so both are insert-only. After a lock at L (the latest locked date):

  • a match or unmatch of a group with cleared_on ≤ L is refused (bank_reconciliation_locked); a group straddling L (e.g. an outstanding cheque matched to a later statement) is allowed, and leaves L’s snapshot true;
  • voiding a statement with ends_on ≤ L is refused;
  • a journal from a line cannot be dated ≤ L (D9);
  • a new statement is unaffected (its ends_on > L by the chain).

Ledger postings dated ≤ L by other modules are not refused — that is period close’s job (§10.4); the snapshot keeps what was signed.

A locked reconciliation is a sign-off and stays. A mistake found later is corrected in the next period: unmatch what is not under the lock, post the correcting journal, reconcile the next statement. (Owner to confirm; an audited “reopen” permission is the alternative.)

Business date is clock.TodayUTC(c); every DATE is midnight UTC. Lock order for every banking write: bank_account row FOR UPDATE → (ledger.Post: fiscal period FOR SHARE, number sequence, its own audit row) → banking rows → banking’s audit.Write, last.

Import, match, journal-from-line and lock are single actions by permission holders, like manual journals and period close today. A two-step approval for journals-from-lines would need a workflow kind, a posting rule and a draft table for what is one line of bookkeeping. (Owner to confirm.)

D16. No reconcile check, no close precondition

Section titled “D16. No reconcile check, no close precondition”

Period close does not require a locked bank reconciliation in v1: close runs the §10.6 set, and a missing bank reconciliation is a management control, not a ledger integrity failure. Add a check to reconcile if the owner wants it (one function, builtin.go’s shape).

API (in api/openapi.yaml first; money is the Money string schema)

Section titled “API (in api/openapi.yaml first; money is the Money string schema)”
Method Path Permission
GET / POST /banking/accounts read / account.manage
GET / PUT /banking/accounts/{id} (PUT with If-Match) read / account.manage
GET / POST /banking/accounts/{id}/statements (POST = import, JSON with content_base64) read / statement.import
GET /banking/statements/{id} (header + lines with their active match) read
GET /banking/statements/{id}/file (the bytes, Content-Disposition: attachment) read
POST /banking/statements/{id}/void statement.import
GET /banking/accounts/{id}/book-lines?from&to&unmatched read
POST /banking/accounts/{id}/auto-match match
POST /banking/matches (kind: manual) 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?as_of read
GET / POST /banking/accounts/{id}/reconciliations (POST = lock) read / reconciliation.lock

The import body is JSON (format, file_name, content_base64, and for CSV starts_on, ends_on, opening_balance?, closing_balance?), limited to 7 MiB by http.MaxBytesReader on that route (5 MiB decoded). JSON keeps the generated client and the strict decoder unchanged; nothing else in the API is multipart.

Accounting sidebar gains Bank accounts (/banking, banking.read). /banking lists bank accounts (DataTable, tableId); a dialog links a GL account (picker: active postable bank accounts not yet linked) with number, currency, match settings and the CSV mapping. /banking/$id has three tabs: Statements (list; import dialog: file, format, CSV dates/balances; void; download file), Matching (unmatched statement lines | unmatched book lines, running totals, Match enabled when equal, Auto-match, Unmatch from a line’s history, “Post to account” on a statement line, Opening clearance for lock holders), Reconciliation (statement date picker from the account’s statement dates, the D11 statement, Lock). en/ms/zh in banking.json.

  • RLS/tenant isolation and retention lists for all eight tables (existing drift tests, once listed).
  • MT940: a two-message file, RD/RC, entry date across a year end, funds code, // bank reference, multi-line :86:, SWIFT wrapper; refusals for a broken :61:, unbalanced message, broken chain, mixed :25:.
  • CSV: signed and debit/credit modes, decimal comma, parentheses, header/footer rows, BOM, newest-first with running balance, a wrong running balance refused naming the row, ISO-8859-1 text.
  • Import: presence first (a file imports), then the same bytes refused; chain refusal (opening ≠ previous closing; ends_on not after); unbalanced refused; void then re-import accepted; void of a non-latest or matched statement refused; lines cannot be added by a later transaction (DB).
  • Match: unbalanced group refused; a line in two active matches refused (Go and DB); unmatch then re-match accepted; history keeps both groups.
  • Auto-match: reference rule, amount/date rule, ambiguity (two equal candidates) leaves both unmatched, window boundary (W and W+1), rule 2 off.
  • Journal from line: posts balanced manual entry with source_kind, matches it; refused on a matched line, on the bank’s own account, on an AP control account (ledger’s refusal), in a locked date.
  • Reconciliation: worked example with an outstanding cheque, a deposit in transit and an unrecorded charge; the difference is invariant under any sequence of matches/unmatches (property test); opening clearance brings it to zero; lock refused at non-zero; lock freezes; match/unmatch/void under the lock refused, straddling match allowed.
  • e2e: link a bank account → post a receipt to it → import an MT940 → auto-match → journal the bank charge → reconciliation difference 0 → owner locks.
  1. Bank statement text is stored verbatim, outside the en/ms record-language rule (it is a copy of the bank’s document). Alternative: refuse or transliterate — refuses any statement with a Chinese payer name, or alters evidence. Confirmed (2026-09-29): it is a copy of third-party evidence; altering it is worse than storing a non-en/ms name.
  2. The raw statement file is kept in Postgres (≤ 5 MiB) for good, never deletable by the app, as the evidence behind every line (ITA s.82A seven years is met by never deleting). Alternative: the object store — needs documents to accept text types. Confirmed (2026-09-29): meets s.82A by never deleting.
  3. Journals from statement lines post without approval by banking.journal.post holders (Finance by default), exactly as manual journals do today. Alternative: an approval workflow kind — one more task, slower month-end. Confirmed (2026-09-29): the cash has already moved (it is on the statement), so recording it adds no exposure; this matches manual journals.
  4. Locking is Owner-only by default and there is no unlock. Alternative: an audited reopen permission; weakens the sign-off. Ruled: Phase 2c C5 / ledger ruling 13 — an audited unlock exists (banking.reconciliation.unlock, Owner, mandatory reason), the latest live lock only.
  5. Opening clearance (clearing pre-go-live book lines without a statement line) is Owner-only. Alternative: anyone with banking.match — easier go-live, weaker control. Confirmed (2026-09-29): opening clearance bypasses a statement line, so it gets the strongest gate.
  6. No preparer ≠ locker rule: one person may import, match and lock (a single-Owner organization could not otherwise reconcile). Alternative: refuse a lock by the member who made any match in it. Changed (2026-09-29, D4): a member who made a manual match, unmatch or opening clearance in the items a lock covers can no longer lock it (auto matches are exempt); a one-member organization uses the audited sole-owner exception (X9).
  7. Statements must be imported in order with an unbroken balance chain; a missing month blocks the next. Alternative: allow gaps — breaks D11’s proof that a difference is only ever a go-live gap. Confirmed (2026-09-29): required for D11’s difference proof.
  8. Auto-match commits its matches (recorded as auto with its rule) instead of proposing them. Alternative: proposals a person confirms — one more click per line. Confirmed (2026-09-29): it is recorded, reversible by unmatch, and reviewed at lock (item 6).
  9. MT940 is implemented to the generic SWIFT layout; no Malaysian bank’s file has been tested. Each bank’s export (Maybank, CIMB, Public Bank, RHB, Hong Leong) should be tried before a customer relies on it. I do not know how each fills :25: or :86:. Needs outside authority: an anonymised MT940/CSV export from Maybank, CIMB, Public Bank, RHB and Hong Leong, added as parser fixtures. Until then, the import screen marks MT940 “untested with your bank” and the CSV mapping stays the fallback.
  10. A locked reconciliation is not exportable in v1 (screen only). Alternative: add bank_reconciliation to the export pipeline now — a pipeline change in parallel with AR’s. Ruled: Phase 2c C5 — report bank_reconciliation (CSV/XLSX/PDF of the locked snapshot) now exists.
  11. Period close does not require a locked bank reconciliation. Alternative: a reconcile check that fails close when the period’s last statement is unlocked. Ruled (owner’s answer, 2026-09-29, BR-11): a period or year close is refused until every active bank account with anything to reconcile is locked through the period end; the Owner may close anyway with a mandatory reason, audited with the list of accounts (accounting.period.close_unreconciled). See the owner-confirmations spec’s Outcome section. A bank-GL line dated inside an account’s latest live lock but posted after it was taken (D2’s back-dated cash) counts as unreconciled for the gate and for deactivation, until a later lock taken after the posting covers it (final review I-2).
  • AR receipts post to bank accounts through their own posting rules; nothing here depends on AR’s interfaces. To be auto-matched by the reference rule, AR’s bank-side journal line memo (or the entry description) should contain the receipt reference and the payer’s bank reference, as AP’s payout does (p.Reference + " " + p.BankReference). AR should not build its own bank matching.
  • Payment batches may read the organization’s own account number for the bank file through banking.GetByGLAccount(ctx, tx, accountID) (platform package, exported by this item); its settlement line’s memo should carry the batch reference so one bulk debit matches by reference.
  • Shared files every Phase 2 item edits (additive merges): internal/kernel/db/migrationranges_test.go (this item adds {350, 369}; note the AP entry {300, 339} overlaps the ranges reserved for AR and payment batches — those items must narrow it), retention_test.go, constants.yaml, api/openapi.yaml, internal/app/app.go and the cmd/* wiring, web/src/app/shell/nav.ts, web/src/app/routes.ts, scripts/dev-stack.sh (lane ports, if no other item has parametrised it first).