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

URL: https://biloh.com.au/docs/concepts/the-balance-chain
Category: Explanation | Audience: operator | Updated: 2026-09-18

## 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](/docs/reference/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](/docs/concepts/envelopes-tax-set-aside-and-contractor-buffer).

## Related

- [Register your bank accounts and import a statement](/docs/how-to/register-bank-accounts-and-import-a-statement)
- [Close a month and a quarter](/docs/how-to/close-a-month-and-a-quarter)
- [Bank import and close refusal codes](/docs/reference/bank-import-and-close-refusal-codes)
