The balance chain: how Biloh knows a month is complete

A bank statement export carries a running balance on every line. Biloh treats that column as proof: if each line's balance equals the previous line's balance plus this line's amount, from the first line of a period to the last, then no line is missing between them. That chain is checked when a statement is imported (a file that does not chain onto the account is refused, and nothing is written) and again before a period is closed (a break refuses the close and names the first gap). It is the reason a month closed in Biloh is complete by arithmetic, not by assumption.

What problem does the chain solve?

Bookkeeping built on a bank feed has one silent failure mode: a missing week. If you export a statement on the 14th and again on the 30th but skip the days between, every figure downstream is wrong and nothing looks wrong. The deposits that landed in the gap are never matched, the spend is never coded, the BAS is short, and the month closes clean.

Biloh removes the assumption. Each line in an internet-banking export carries the account's running balance after that line. Those balances are the bank's own arithmetic, and they chain: this line's balance is the previous line's balance plus this line's signed amount. If the chain holds from the first line of a period to the last, no line is missing between them. If it breaks, the break names the exact span and the exact amount unaccounted for.

Where is the chain checked?

On import. import_bank_csv routes every line into a named bank account. Before a single row is written, the batch's implied opening balance must chain onto that account: its current closing balance, its declared opening balance on a first import, or a balance it has already recorded when a window is re-imported. If it does not, the whole import is refused with wrong_account_or_gap, naming the expected and actual figures, and zero rows land. That is either the wrong file for this account or a missing statement window, and both have the same fix: export the right days.

Before a close. run_period_close ties every line in the whole range by running balance before anything is written. A break anywhere refuses the close with bank_completeness_failed, naming the first break's date and the gap in cents.

On demand. get_period_completeness is the read-only version: it returns chain_intact, the line count, the opening and closing balances, the coverage span, and, on a break, first_break with the previous and current value dates, the expected and actual balances and the gap. list_bank_accounts carries chain_intact per account alongside the feed's freshness.

What counts in the chain?

Every bank line in the period, whatever its match state: matched, unmatched, ignored, split or paired as a transfer. They are all real movements of money, and excluding any of them would manufacture a false hole. Soft-deleted lines are excluded. Lines flagged as test data are excluded unless you ask for them.

An empty period reports no_lines_in_period: true with an intact chain and a line count of zero. An empty month is empty, never "intact" without qualification. A line with no running balance cannot be tied and is counted in unverifiable; the chain then reports as not intact, because failing closed is the only safe direction for a completeness proof.

Re-imports, overlaps and the same-day problem

Import is idempotent. Re-importing an overlapping window adds zero duplicates, and two genuinely distinct same-day, same-payer, same-amount credits both survive. Within one day, lines are ordered by import order, and the completeness check searches for an order that ties rather than assuming the export's order was the bank's posting order.

What if the gap is real?

Sometimes a window is genuinely unrecoverable. Two doors exist, both loud:

  • An import can be forced over a known gap with override_chain_gap and a required override_reason. An empty reason is refused with override_reason_required. The override is audit-logged.
  • A close can be signed off over a break with a reason. The sign-off is recorded in the audit log, and the completeness read still reports the break afterwards. The figures are not pretended complete; the operator has taken responsibility for the hole.

How hard the import refuses is a tenant setting, finance.bankfeed.chain_guard (default enforce). See finance settings.

Why several accounts?

A service business typically runs an everyday operating account, a tax or BAS set-aside, and a contractor buffer. Each is registered once with create_bank_account (a masked identifier only, never a full account number) and keeps its own chain. A statement for the set-aside imported into the everyday account is refused on the spot, because its opening balance cannot chain. Money moved between two of your own accounts is paired as a transfer and stays a real line in both chains, so no balance moves by a cent. See envelopes.

Related

Last updated 2026-09-18