Skip to content

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.

A statement reaches the books only by importing an MT940 or CSV file, and it is frozen on commit. Two gaps follow:

  1. 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.
  2. Import writes immediately. The user sees the statement only after it is stored, with its auto-matches already made.
  • 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).

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_guard backstop (00354) compares statement_balance with the statement’s closing; it compares with the effective closing instead.
  • difference stays independent of matching: manual lines are statement lines, so they appear in U_s until matched and move S and U_s by 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_line gains manual BOOLEAN NOT NULL DEFAULT false, reason TEXT, added_by UUID, added_at TIMESTAMPTZ. CHECKs: manual iff the three others are non-null; reason 1-500 chars with the record-language CHECK that void_reason has (D3 exempts only the bank’s own text; the reason is ours). Manual lines carry bank_reference = '' and transaction_code = ''.
  • line_no continues 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 allows manual = true when the statement is the account’s latest non-voided one and its ends_on is after the latest locked reconciliation. It takes the bank_account row FOR UPDATE first (D14 lock order). Imported lines keep today’s rule.
  • bank_statement_check (the deferred balance trigger) counts and sums NOT manual lines only, so the stated figures still prove the file. A new SQL function bank_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.

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.

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 409 duplicate_file error with statement_id, and a chain mismatch is the existing 422 opening_mismatch, so the preview needs no extra fields for them.
  • the first refusal Import would 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).

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.

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).

  • ImportStatementDialog.tsx becomes 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 with added_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 holds banking.statement.import and 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).

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 the bank_statement_guard backstop.
  • Balanced check: a manual line does not trip bank_statement_balanced; an imported line that does not balance still does.
  • Append-only: UPDATE and DELETE on a manual line are refused by privilege.
  • Preview writes nothing: row counts of bank_statement, bank_statement_line, bank_match, audit_log and 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.
  • 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).

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.