DEV Community

Damin Mutti
Damin Mutti

Posted on Edited on

Detecting a missing bank statement from balance carry-forward (not from dates)

To find a missing bank statement, sort each account's statements by period and compare every statement's closing balance with the next statement's opening balance. They should be identical to the cent. When they aren't, something sits between them: a missing statement, a missing page, or a statement from a different account mixed in. The difference tells you how much activity you haven't seen.

Checking dates alone misses things. Balance carry-forward doesn't, because the bank has already done the arithmetic for you.

Why dates aren't enough

The obvious check is "do I have a statement for every month?" It fails in at least four ways:

  • Statement cycles often don't follow calendar months. A cycle of the 15th to the 14th means "October" and "November" are both partly in one statement.
  • Some banks issue an extra statement when an account changes (a product switch, a new account number, a fraud reissue). The months look complete but a short statement is missing in the middle.
  • Two statements for different accounts at the same bank look like one complete series if you only read dates.
  • A statement can be present but truncated: the first page is there, the transaction pages aren't.

Balances catch all of these.

The data

One row per statement, as printed in each statement's summary:

from dataclasses import dataclass
from datetime import date
from decimal import Decimal as D

@dataclass
class Stmt:
    account: str        # last 4 is usually enough
    start: date
    end: date
    opening: D
    closing: D
    source: str         # file name, so you can point to the PDF

statements = [
    Stmt("2208", date(2026, 3, 15), date(2026, 4, 14), D("8102.44"), D("9930.18"), "mar.pdf"),
    Stmt("2208", date(2026, 4, 15), date(2026, 5, 14), D("9930.18"), D("7415.60"), "apr.pdf"),
    Stmt("2208", date(2026, 6, 15), date(2026, 7, 14), D("11288.02"), D("10640.95"), "jun.pdf"),
    Stmt("2208", date(2026, 7, 15), date(2026, 8, 14), D("10640.95"), D("12004.31"), "jul.pdf"),
    Stmt("5519", date(2026, 7, 1),  date(2026, 7, 31), D("2250.00"), D("1980.40"), "sav-jul.pdf"),
]
Enter fullscreen mode Exit fullscreen mode

The check

from itertools import groupby

def chain(statements):
    findings = []
    key = lambda s: s.account
    for account, group in groupby(sorted(statements, key=lambda s: (s.account, s.start)), key=key):
        prev = None
        for s in group:
            if prev:
                gap_days = (s.start - prev.end).days
                if s.start <= prev.end:
                    findings.append((account, "overlap", prev.source, s.source, None))
                elif s.opening != prev.closing:
                    findings.append((account, "balance gap", prev.source, s.source, s.opening - prev.closing))
                elif gap_days > 1:
                    findings.append((account, "date gap, balances agree", prev.source, s.source, D("0")))
            prev = s
    return findings

for f in chain(statements):
    print(f)
Enter fullscreen mode Exit fullscreen mode

Output:

('2208', 'balance gap', 'apr.pdf', 'jun.pdf', Decimal('3872.42'))
Enter fullscreen mode Exit fullscreen mode

April's statement closes at 7,415.60. June's opens at 11,288.02. The May 15 to June 14 statement is missing, and $3,872.42 of net activity is in it. That is a specific, short request to make: "please send the statement for account 2208 ending June 14."

Account 5519 has only one statement, so it can't fail a chain check. That's worth flagging separately: a single statement proves nothing about completeness.

The case where dates have a gap but balances agree

The "date gap, balances agree" branch is there on purpose. It usually means the account had no activity, and some banks don't issue a statement for an empty cycle. That's fine. But it can also mean the missing statement had deposits and withdrawals that net to exactly zero. Rare, but in litigation work rare is the point. Flag it, don't fail it.

Pair it with the per-statement check

The chain check assumes each statement is internally right. Prove that first: opening plus the statement's own transactions should equal its closing. If a statement fails that, its closing balance is still printed by the bank, so the chain check still works, but the transaction rows you extracted are incomplete.

ZeraBooks Convert: the March 2025 and May 2025 statements are each proven to the cent, but March closes at 18,240.55 and May opens at 21,905.10, so April is missing
Five statements uploaded to ZeraBooks Convert, each proven to the cent on its own. Setting March's closing beside May's opening is what shows April is missing.

So the order is:

  1. Each statement ties to itself (rows add up from opening to closing).
  2. Each account's statements chain (closing equals the next opening).
  3. Request anything missing, and rerun 1 and 2 on what arrives.
  4. Only then, start analysis, with the source file kept on every row.

Where this matters most

Forensic accountants, divorce teams and lenders all receive statement productions from someone else, and an incomplete production is the norm, not the exception. The ZeraBooks write-up on forensic bank statement analysis covers the columns reviewers want once the set is complete.

It matters in ordinary bookkeeping too. Disclosure: I work on ZeraBooks, and its general ledger software reconciles every month against the uploaded statement, so a month that doesn't carry forward shows up as a month that doesn't tie, instead of a quiet hole in the books.

Top comments (0)