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.
Not the reconciliation set
Section titled “Not the reconciliation set”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_exportparameters are period/as-of/scope, not “reconciliation id”, so it is a pipeline change, and AR is editingreport_export_report_knownin 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).
Decisions
Section titled “Decisions”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_idnames an existing active, postableaccountwithcontrol_kind = 'bank'(the chart already allows many:account_one_control_per_kindexcludesbank, 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).
currencyis the statement currency. If the GL account has a currency it must equal it; if the GL account’s currency is null (any),currencymust be the base currency. Enforced in Go and by the insert triggerbank_account_gl_is_bank.account_idandcurrencyare fixed once created (bank_account_identity_fixed).account_numberis 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:documentsverifies images/PDF by magic bytes only, statement files are small text, and a BYTEA column needs no new store path.GET /banking/statements/{id}/filereturns 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’sposted_txidpattern), so nothing is appended to a statement later. - Chain: for a bank account, a new statement’s
ends_onmust be after the latest non-voided statement’sends_on, and itsopening_balancemust equal that statement’sclosing_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_onafter 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.
D4. MT940
Section titled “D4. MT940”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 dateYYMMDD, optional entry dateMMDD(year inferred from the value date, nearest within ±6 months), markD/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’saccount_numberas a digit substring (422account_mismatch) and the currency must be the bank account’s (422currency_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).
D5. CSV with a per-account column mapping
Section titled “D5. CSV with a per-account column mapping”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 abalance_column, opening and closing are derived and every row’s running balance is verified (422running_balancenaming the row); without one,closing_balanceis required (typed from the bank’s statement) andopening_balancedefaults to the previous statement’s closing (required for the first statement). Either way D3’s checks apply. - Parser:
encoding/csvwithLazyQuotes,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(withrule∈reference|amount_date),manual,journal(D9),opening(D8).- A group other than
openinghas 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 readsjournal_lineon the GL account, never a module’s tables. - A line is in at most one active match: an insert trigger on
bank_match_itemtakes the bank account rowFOR UPDATE(serializing every match/unmatch/lock on the account) and refuses a second active membership (bank_match_item_line_free).journal_linecannot 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’sends_onand a book line’s date is its entry date. Stored at insert, verified by the deferred triggerbank_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:
- reference — equal amounts,
|booked_on − entry_date| ≤ window, and a reference token of the book line appears in the statement line’sreference + bank_reference + description. Tokens come from the entry number, the entry description and the line memo (where AP putsSP-…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). - 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).
D8. Opening clearance, for go-live
Section titled “D8. Opening clearance, for go-live”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.gopostJournalEntry→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 byledger.Post; thePoster(accounting.Poster) only runs on workflow transitions and there is no workflow here (D15). entry_datedefaults to the line’sbooked_on; if that is on or before the latest locked reconciliation the request is 409reconciliation_lockedand the caller passes a later date.account_idmay 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
journalgroup is exactly one statement line and one book line whose entry hassource_kind = 'bank_statement_line'andsource_id= that statement line. - Reversing that entry later through
/accounting/entries/{id}/reversalis 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.
D10. Permissions
Section titled “D10. Permissions”| 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).
D11. The reconciliation statement
Section titled “D11. The reconciliation statement”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 ≤Dnot reconciled as ofD(deposits in transit are positive, unpresented payments negative). - Unrecorded statement items
U_s= lines of non-voided statements withends_on ≤ Dnot reconciled as ofD(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.
D12. Locking
Section titled “D12. Locking”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 ≤ Lis refused (bank_reconciliation_locked); a group straddlingL(e.g. an outstanding cheque matched to a later statement) is allowed, and leavesL’s snapshot true; - voiding a statement with
ends_on ≤ Lis refused; - a journal from a line cannot be dated
≤ L(D9); - a new statement is unaffected (its
ends_on > Lby 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.
D13. No unlock
Section titled “D13. No unlock”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.)
D14. Business dates and lock order
Section titled “D14. Business dates and lock order”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.
D15. No workflow kind
Section titled “D15. No workflow kind”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.
Tests that must exist
Section titled “Tests that must exist”- 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_onnot 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.
For the owner to confirm
Section titled “For the owner to confirm”- 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.
- 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
documentsto accept text types. Confirmed (2026-09-29): meets s.82A by never deleting. - Journals from statement lines post without approval by
banking.journal.postholders (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. - 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. - 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. - 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).
- 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.
- Auto-match commits its matches (recorded as
autowith 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). - 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. - A locked reconciliation is not exportable in v1 (screen only). Alternative: add
bank_reconciliationto the export pipeline now — a pipeline change in parallel with AR’s. Ruled: Phase 2c C5 — reportbank_reconciliation(CSV/XLSX/PDF of the locked snapshot) now exists. - Period close does not require a locked bank reconciliation. Alternative: a
reconcilecheck 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).
Dependencies on other Phase 2 items
Section titled “Dependencies on other Phase 2 items”- 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.goand thecmd/*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).