Bank statements: manual lines and an import preview
Status: design, approved in conversation 2026-10-06, awaiting written-spec review.
Extends 2026-09-27-bank-reconciliation-design.md
(D1-D16). Section numbers D* below refer to it; this spec’s own are M1-M9.
Problem
Section titled “Problem”A statement reaches the books only by importing an MT940 or CSV file, and it is frozen on commit. Two gaps follow:
- A bank file that omits a transaction cannot be completed. The user can only void and re-import, and cannot even do that once a later statement exists.
- Import writes immediately. The user sees the statement only after it is stored, with its auto-matches already made.
Decisions taken (owner, 2026-10-06)
Section titled “Decisions taken (owner, 2026-10-06)”- Manual lines are added to an existing statement, not as a separate manual statement.
- They do not edit the statement row: effective closing = stated closing + manual lines.
- Only the account’s latest non-voided statement accepts them.
- Permission: the existing
banking.statement.import(no new key, no grant migration). - Import gets a preview before anything is stored. Lines may be added in the preview. They may not be deleted there: a wrong line is fixed in the file and re-imported (cancel stores nothing). Delete-as-exclude was considered and refused (M9).
M1. Effective closing is the one balance
Section titled “M1. Effective closing is the one balance”The D11 identity is “S = first opening + sum of statement lines up to D”. It survives if manual
lines count as statement lines in S. So everywhere that reads a statement’s closing balance for
reconciliation or chaining reads effective_closing = closing_balance + sum(manual line amounts).
- Reconciliation
S(internal/platform/banking/reconciliation.go,StatementBalance:). - The chain: the next statement must open at the previous effective closing. This is the
point of the feature: the bank’s next file already includes the transaction its earlier file
omitted. Both the Go check (
ErrNotAfterPrevious/OpeningMismatchError,Expected) and the SQL backstop (bank_statement_guard,NEW.opening_balance <> v_prev.closing_balance) change. - CSV import with no opening given defaults to the previous effective closing.
- The
bank_reconciliation_guardbackstop (00354) comparesstatement_balancewith the statement’s closing; it compares with the effective closing instead. differencestays independent of matching: manual lines are statement lines, so they appear inU_suntil matched and moveSandU_sby the same amount. The D11 property test is extended to prove it (M8).
The statement row, closing_balance, line_count, file_bytes and file_sha256 are never
written again. The file download still returns the exact bytes the bank sent.
M2. Storage: manual lines are rows of bank_statement_line
Section titled “M2. Storage: manual lines are rows of bank_statement_line”Matching (bank_match_item.statement_line_id), the match triggers, cleared_on, auto-match,
journal-from-line and the reconciliation snapshot all read bank_statement_line. Putting manual
lines in a second table would need a third side on every one of them. Instead, new migration:
bank_statement_linegainsmanual BOOLEAN NOT NULL DEFAULT false,reason TEXT,added_by UUID,added_at TIMESTAMPTZ. CHECKs:manualiff the three others are non-null;reason1-500 chars with the record-language CHECK thatvoid_reasonhas (D3 exempts only the bank’s own text; the reason is ours). Manual lines carrybank_reference = ''andtransaction_code = ''.line_nocontinues after the highest existing number (UNIQUE (org_id, statement_id, line_no)already holds).bank_statement_line_guard(00351) today allows an insert only in the transaction that wrote the statement. It additionally allowsmanual = truewhen the statement is the account’s latest non-voided one and itsends_onis after the latest locked reconciliation. It takes thebank_accountrowFOR UPDATEfirst (D14 lock order). Imported lines keep today’s rule.bank_statement_check(the deferred balance trigger) counts and sumsNOT manuallines only, so the stated figures still prove the file. A new SQL functionbank_statement_effective_closing(org, statement)is the one definition M1’s readers share.- UPDATE and DELETE stay revoked on the table (append-only).
- Void is unchanged: manual lines stay on the voided statement. The statement is already blocked from voiding while any line is actively matched.
M3. Add a line to a committed statement
Section titled “M3. Add a line to a committed statement”POST /banking/statements/{id}/lines, body { booked_on, amount, description, reference?, reason }
(amount a signed Money string, never zero). Requires banking.statement.import.
One transaction: lock the account row, re-read the statement, then refuse unless all hold:
| Refusal | Status / code |
|---|---|
| statement voided | 409 voided (existing error) |
| not the latest non-voided statement | 409 not_latest (new ErrAddLineNotAllowed) |
ends_on on or before the latest locked reconciliation |
409 reconciliation_locked |
booked_on outside starts_on..ends_on |
422 field=booked_on rule=period |
| amount zero or malformed, reason missing or not en/ms | 422 as Import does |
Then insert the line, run auto-match for the account (as import does; the new line may pair with
an existing book line), write the audit row (EntityBankStatement, action add_line, with the
line, reason and the resulting effective closing). No event is published: a webhook event is a public-API contract (catalog, schema, counsel gate) and nobody asked for one. Returns the line and the effective closing.
M4. Import preview
Section titled “M4. Import preview”POST /banking/accounts/{id}/statements/preview, the same body as import. Requires
banking.statement.import. It writes nothing (no row, no audit, no auto-match, no event) and
returns:
- the parsed header: format, period, opening, closing, stated balances, line count;
- every line;
previous_effective_closing(null for a first statement). A duplicate file is the existing 409duplicate_fileerror withstatement_id, and a chain mismatch is the existing 422opening_mismatch, so the preview needs no extra fields for them.- the first refusal
Importwould give, as the same coded 4xx error (so a file that cannot be imported fails at preview with the same message, not at confirm).
To keep the two from drifting, Import is split into prepareImport (parse, every check, no
write) and the writer. Preview calls only prepareImport; Import calls both. A drift test
imports and previews the same fixtures and requires the same accept/refuse outcome and the same
error code (M8).
M5. Confirm with added lines
Section titled “M5. Confirm with added lines”The import request gains an optional added_lines: an array of the M3 line shape (max 200).
Import validates them in prepareImport (period, amount, reason, record language), inserts
them after the imported lines in the same transaction as manual = true, and audits them in the
import’s own audit row. The preview never writes, so the added lines live only in the browser
until confirm. Balance is unaffected: M2’s check ignores manual lines, and M1 gives the effective
closing.
The file’s bytes and hash are exactly what was uploaded, as today. A re-confirm after editing is a second request with the same file and refused as a duplicate only if the first committed.
M6. API (api/openapi.yaml first)
Section titled “M6. API (api/openapi.yaml first)”| Method | Path | Notes |
|---|---|---|
| POST | /banking/accounts/{id}/statements/preview |
new, M4 |
| POST | /banking/accounts/{id}/statements |
added_lines? added, M5 |
| POST | /banking/statements/{id}/lines |
new, M3 |
| GET | /banking/statements/{id} |
adds effective_closing; each line adds manual, reason, added_by, added_at |
| GET | /banking/accounts/{id}/statements |
Statement adds effective_closing |
Handlers implement the generated interfaces, so drift fails to compile. The generated TS client and Go server regenerate from the one file (the generated-code drift test covers it).
M7. Web
Section titled “M7. Web”ImportStatementDialog.tsxbecomes two steps. Step 1 is today’s form. Its submit calls preview. Step 2 shows the header (period, opening, stated closing, effective closing), the chain status, a duplicate warning, and the line table. Add line opens an inline row (date, amount, description, reference, reason) and shows the effective closing live. Confirm import calls import withadded_lines; Back returns to step 1 with the file kept. A manual line in the preview offers Remove (it is client state, never stored). Imported lines have no delete control.StatementsTab.tsx: Add line goes on the Statements tab row of the latest non-voided statement (there is no statement detail screen) when the user holdsbanking.statement.importand the period is unlocked; the “Manual” badge (with who added the line and the reason) goes on the statement-line rows of the Matching tab; the statements row shows stated and effective closing.- Every new user-facing string is a translation row in en, ms and zh. ms and zh are marked for native review like the rest (nothing is signed off).
M8. Tests and guards
Section titled “M8. Tests and guards”Each is watched failing once before it is relied on, and each asserts presence before absence.
- Property: extend the D11 property test with manual lines: difference is unchanged by adding them and by matching them.
- Frozen earlier statement: with a later statement present, adding a line is 409
not_latest. Presence step: the same add on the latest statement succeeds. - Locked period: after a lock at or beyond
ends_on, add is refused; before the lock it succeeded. Also asserted at the SQL backstop by inserting through the owner connection with the app guard bypassed. - Chain: after adding a line, a next statement opening at the old stated closing is refused
(
OpeningMismatchError,Expected= effective closing) and one opening at the effective closing is accepted. Both in Go and at thebank_statement_guardbackstop. - Balanced check: a manual line does not trip
bank_statement_balanced; an imported line that does not balance still does. - Append-only:
UPDATEandDELETEon a manual line are refused by privilege. - Preview writes nothing: row counts of
bank_statement,bank_statement_line,bank_match,audit_logand the outbox are equal before and after a preview, and non-zero after the same body is imported (presence). - Preview/import drift: M4’s shared-outcome test over the existing fixtures, including the CSV, MT940, duplicate, account-mismatch and not-after-previous cases.
- Tenant isolation, permissions and RLS drift tests cover the new columns and routes by
their existing generic tests; the route test confirms the two new routes are behind
httpx.Authorize. - Web: preview step renders and cancels without a write; confirm sends
added_lines; add-line row updates the effective closing; Remove only exists on manual lines.
M9. Out of scope
Section titled “M9. Out of scope”- Deleting a line. The stated closing is the bank’s figure and the file hash proves what it sent; hiding a line would make the stored record disagree with both. A wrong imported line is fixed in the file and re-imported, or (after commit) handled by void-and-reimport while it is still the latest, or by matching and a journal from the line. An audited exclusion table is the upgrade if the owner later wants it.
- Editing a manual line: it is append-only. A wrong one is offset by a second line with a reason.
- Adding lines to a statement that is not the latest. A later statement was validated against the earlier effective closing and would no longer chain.
- A separately keyed-in “manual statement” with no file.
- Bulk add of manual lines after commit (the preview carries up to 200).
Migration and deploy
Section titled “Migration and deploy”One migration: the bank_statement_line columns and CHECKs, the revised line guard, the
revised balance check, bank_statement_effective_closing, and the revised bank_statement_guard
chain test. It runs against existing data without a backfill (all existing lines are
manual = false; effective closing equals closing). No grants change. Down migration restores
the 00351 functions and drops the columns; it is refused while any manual row exists.