DEV Community

Hongwei Wang
Hongwei Wang

Posted on

Your PDF bank statement parser is wrong somewhere. Balance checks tell you where.

If you have ever pulled transactions out of a PDF bank statement, with pdfplumber, Camelot, an OCR pass or an LLM, you know the extraction part is not the hard part. The hard part is knowing whether the output is right.

A dropped row or a minus sign read as a plus doesn't crash anything. The CSV looks fine, it imports fine, and three weeks later somebody's books are off by $15.00 and nobody knows why.

The good news is that a bank statement checks itself. It prints an opening balance, a closing balance, often totals for money in and money out, and on many statements a running balance on every row. That is enough redundancy to verify an extraction without knowing anything about the bank's layout, and to point at the exact row that is wrong.

This post builds that checker in plain Python (standard library only, about 140 lines), then covers the pitfalls that break it and what to watch for when you write OFX or QBO files from the result.

All code in this post was run on Python 3.13 and is covered by a small pytest suite (13 tests, passing).

The invariant

For any statement period:

opening balance + sum(signed transactions) == closing balance
Enter fullscreen mode Exit fullscreen mode

And if the statement prints a running balance on each row:

balance[i] == balance[i-1] + amount[i]      (balance[-1] is the opening balance)
Enter fullscreen mode Exit fullscreen mode

The first check tells you whether something is wrong. The second tells you where. If the summary box prints total credits and total debits, those are two more independent checks, and they help separate "a row is missing" from "a sign is flipped": a flipped sign moves money between the two totals without changing how many rows there are.

Step 1: parse amounts into Decimal, never float

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, InvalidOperation

CENT = Decimal("0.01")

def parse_amount(raw: str, decimal_comma: bool = False) -> Decimal:
    """'1,234.56'  '(1,234.56)'  '1,234.56-'  '$-12.00'  '45.00 CR'  '1.234,56'"""
    s = raw.strip().upper().replace("$", "").replace("\u2212", "-").replace(" ", "")
    negative = False
    if s.endswith("DR"):
        negative, s = True, s[:-2]
    elif s.endswith("CR"):
        s = s[:-2]
    if s.startswith("(") and s.endswith(")"):
        negative, s = True, s[1:-1]
    if s.endswith("-"):
        negative, s = True, s[:-1]
    if s.startswith("-"):
        negative, s = not negative, s[1:]
    if decimal_comma:
        s = s.replace(".", "").replace(",", ".")
    else:
        s = s.replace(",", "")
    if not re.fullmatch(r"\d+(\.\d{1,2})?", s):
        raise ValueError(f"not an amount: {raw!r}")
    try:
        value = Decimal(s)
    except InvalidOperation as exc:
        raise ValueError(f"not an amount: {raw!r}") from exc
    return -value if negative else value
Enter fullscreen mode Exit fullscreen mode

Use Decimal from the very first character. With floats, 0.1 + 0.2 != 0.3, and an equality check on balances turns into a tolerance check that will eventually hide a real one-cent error.

Things this handles because real statements do all of them:

  • Thousands separators: 1,234.56. European statements flip them (1.234,56), so the caller has to say which convention the document uses. Don't guess per row: 1.234 is either one thousand two hundred thirty-four or one point two three four, and only the document as a whole can tell you which.
  • Negative formats: leading minus, trailing minus (84.17-), parentheses ((600.00)), the Unicode minus sign − that some PDF generators emit, and CR/DR suffixes.
  • Reject anything else: the fullmatch at the end is deliberate. If an extractor hands you 1,2345.6.7, raising is much better than storing a number.

Step 2: dates without years

Most statements print 12/30 and 01/02 with no year, and the period header says something like "December 15, 2025 through January 14, 2026". Infer the year from the period, not from today's date:

def parse_day(raw: str, period_start: date, period_end: date, day_first: bool = False) -> date:
    """'01/03' with no year: take the year from the statement period."""
    a, b = (int(x) for x in re.split(r"[/.-]", raw.strip())[:2])
    month, day = (b, a) if day_first else (a, b)
    for year in range(period_start.year, period_end.year + 1):
        candidate = date(year, month, day)
        if period_start <= candidate <= period_end:
            return candidate
    raise ValueError(f"{raw!r} is outside {period_start}..{period_end}")
Enter fullscreen mode Exit fullscreen mode

Two notes:

  • Year rollover. A December–January statement is the classic bug: 01/02 parsed with the opening year lands eleven months in the future.
  • Day-first vs month-first is a per-bank (and per-country) setting. 02/01 is ambiguous on its own. If you have to detect it, look across the whole statement: any value above 12 in the first position settles it.

Step 3: the model

@dataclass
class Txn:
    day: date
    description: str
    amount: Decimal                 # signed: money in > 0, money out < 0
    balance: Decimal | None = None  # running balance printed on the row, if any
    page: int = 1


@dataclass
class Problem:
    row: int      # 0-based index into the transaction list
    message: str
Enter fullscreen mode Exit fullscreen mode

One sign convention for everything: positive means money into the account, negative means money out. Convert at parse time. If a statement has separate "Withdrawals" and "Deposits" columns, the column decides the sign, and that is exactly where extractors go wrong when a column boundary shifts.

Step 4: check every row, and guess why it failed

def _looks_like_transposition(gap: Decimal) -> bool:
    cents = int((abs(gap) / CENT).to_integral_value())
    return cents != 0 and cents % 9 == 0


def check_running_balance(opening: Decimal, txns: list[Txn]) -> list[Problem]:
    """Check every row whose running balance was printed, and guess why a row fails."""
    problems: list[Problem] = []
    prev = opening
    for i, t in enumerate(txns):
        if t.balance is None:           # some banks print one balance per day
            prev += t.amount
            continue
        expected = prev + t.amount
        if expected == t.balance:
            prev = t.balance
            continue
        gap = t.balance - expected
        if prev - t.amount == t.balance:
            hint = "sign looks flipped (debit read as credit or vice versa)"
        elif i > 0 and txns[i - 1].balance == t.balance and txns[i - 1].amount == t.amount:
            hint = "row repeats the previous row, probably duplicated at a page break"
        elif t.page != (txns[i - 1].page if i else t.page):
            hint = f"first row on page {t.page}: {gap:+} is missing, likely a row lost at the break"
        elif _looks_like_transposition(gap):
            hint = f"off by {gap:+}, divisible by 9: possible transposed digits"
        else:
            hint = f"off by {gap:+}: a row may be missing just before this one"
        problems.append(Problem(i, f"{t.day} {t.description!r}: expected {expected}, "
                                   f"statement says {t.balance}; {hint}"))
        prev = t.balance                # resync so one error is reported once
    return problems
Enter fullscreen mode Exit fullscreen mode

A few design decisions matter here:

  • Resync after a failure. After a bad row, continue from the balance the statement printed, not from your computed one. Otherwise one error makes every following row fail, and the report is useless.
  • Rows without a balance. Some banks print the balance once per day. Accumulate those rows and check at the next row that has one.
  • The hints are heuristics, ordered from most to least specific. Each is a simple arithmetic fact:
    • If subtracting the amount instead of adding it gives the printed balance, the sign is flipped.
    • If the row has the same amount and the same running balance as the previous row, it can't be a real second transaction (a real one would move the balance). It was read twice, usually where a page ends and the next begins.
    • If the first row of a new page is off, the missing amount is exactly the gap. That is the "lost a line at the page break" bug, and the gap tells you the amount to look for.
    • If the gap in cents is divisible by 9, suspect transposed digits (54.99 vs 45.99). This is an old bookkeeper's trick: swapping two adjacent digits always changes a number by a multiple of 9.

Then the statement-level check, including the summary totals if the statement prints them:

def check_statement(opening: Decimal, closing: Decimal, txns: list[Txn],
                    printed_credits: Decimal | None = None,
                    printed_debits: Decimal | None = None) -> list[str]:
    report: list[str] = []
    total = sum((t.amount for t in txns), Decimal("0"))
    if opening + total != closing:
        report.append(f"TOTAL: {opening} + {total} = {opening + total}, "
                      f"but the closing balance is {closing} "
                      f"(gap {closing - opening - total:+})")
    credits = sum((t.amount for t in txns if t.amount > 0), Decimal("0"))
    debits = -sum((t.amount for t in txns if t.amount < 0), Decimal("0"))
    if printed_credits is not None and credits != printed_credits:
        report.append(f"CREDITS: extracted {credits}, summary says {printed_credits}")
    if printed_debits is not None and debits != printed_debits:
        report.append(f"DEBITS: extracted {debits}, summary says {printed_debits}")
    report += [f"ROW {p.row + 1}: {p.message}" for p in check_running_balance(opening, txns)]
    return report
Enter fullscreen mode Exit fullscreen mode

Running it on a broken extraction

Here is a December–January statement with four realistic mistakes planted in it: a lost sign, a row dropped at the page break, a row read twice and a misread digit.

from datetime import date
from decimal import Decimal as D

from reconcile import Txn, check_statement, parse_amount, parse_day, with_fitids, ofx_stmttrn

start, end = date(2025, 12, 15), date(2026, 1, 14)
rows = [  # (date, description, amount as printed, balance as printed, page)
    ("12/16", "CARD 4411 OFFICE DEPOT",  "84.17-",     "4,915.83",  1),
    ("12/19", "ACH DEPOSIT STRIPE",      "1,250.00",   "6,165.83",  1),
    ("12/22", "CHECK 1043",              "(600.00)",   "5,565.83",  1),
    ("12/30", "ONLINE TRANSFER TO SAV",  "500.00",     "5,065.83",  1),   # sign lost
    # page 2 starts; the extractor dropped "12/31 MONTHLY FEE 15.00-" (5,050.83)
    ("01/02", "ACH DEPOSIT STRIPE",      "980.00",     "6,030.83",  2),
    ("01/02", "ACH DEPOSIT STRIPE",      "980.00",     "6,030.83",  2),   # duplicated
    ("01/06", "CARD 4411 ADOBE",         "54.99-",     "5,984.84",  2),   # really 45.99
    ("01/09", "ZELLE FROM J SMITH",      "200.00",     "6,184.84",  2),
]
txns = [Txn(parse_day(d, start, end), desc, parse_amount(a), parse_amount(b), p)
        for d, desc, a, b, p in rows]

for line in check_statement(opening=D("5000.00"), closing=D("6184.84"), txns=txns,
                            printed_credits=D("2430.00"), printed_debits=D("1245.16")):
    print(line)

print()
# Two real, identical fees on a statement that prints no running balance:
fees = [Txn(date(2026, 1, 9), "ATM FEE", D("-2.50")), Txn(date(2026, 1, 9), "ATM FEE", D("-2.50"))]
for fid, t in with_fitids("CHK-1234", fees):
    print(ofx_stmttrn(fid, t))
Enter fullscreen mode Exit fullscreen mode

Output:

TOTAL: 5000.00 + 3170.84 = 8170.84, but the closing balance is 6184.84 (gap -1986.00)
CREDITS: extracted 3910.00, summary says 2430.00
DEBITS: extracted 739.16, summary says 1245.16
ROW 4: 2025-12-30 'ONLINE TRANSFER TO SAV': expected 6065.83, statement says 5065.83; sign looks flipped (debit read as credit or vice versa)
ROW 5: 2026-01-02 'ACH DEPOSIT STRIPE': expected 6045.83, statement says 6030.83; first row on page 2: -15.00 is missing, likely a row lost at the break
ROW 6: 2026-01-02 'ACH DEPOSIT STRIPE': expected 7010.83, statement says 6030.83; row repeats the previous row, probably duplicated at a page break
ROW 7: 2026-01-06 'CARD 4411 ADOBE': expected 5975.84, statement says 5984.84; off by +9.00, divisible by 9: possible transposed digits

<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20260109<TRNAMT>-2.50<FITID>f3a437a4721706bff861de75<NAME>ATM FEE</STMTTRN>
<STMTTRN><TRNTYPE>DEBIT<DTPOSTED>20260109<TRNAMT>-2.50<FITID>6820a24698b934b81a276954<NAME>ATM FEE</STMTTRN>
Enter fullscreen mode Exit fullscreen mode

Every planted error is reported once, on the right row, with a usable hint. The total check alone would only have told you "off by -1,986.00", which is a mix of four different problems and not much use to a human.

The pitfalls that break this checker

Page breaks. Most bugs live here. Watch for three things: the last row of a page clipped by a footer, a "balance carried forward" / "balance brought forward" line extracted as if it were a transaction (it has the previous balance as its amount, and it blows up the total), and a row repeated at the top of the next page. Strip carried-forward lines before checking; the running-balance check catches the other two.

Multi-line descriptions. A wrapped description line sometimes gets its own row with no amount, or steals the amount from the row below. Merge description-only lines into the row above before checking.

Credit card statements invert the meaning of "balance". The balance is what you owe, so a purchase raises it. Either flip the signs for card accounts before running the checker, or flip the formula. Pick one place to do it and test it. In OFX, card purchases are still negative TRNAMT, because amounts are from the account holder's point of view.

Pending and "memo" lines. Some statements list holds or informational lines that don't affect the balance. The running balance check will flag them; that's correct. Exclude them and keep a list of what you excluded.

Rounding in the summary box. It shouldn't happen, and it does: summaries in a different currency, or interest shown with more decimals. Don't widen your tolerance globally. Treat it as a per-field rule.

A statement can balance and still be wrong. If a row's amount and its printed balance are both misread consistently (rare, but OCR does it), every check passes. Balance checks prove the output is consistent with the document. They don't replace looking at the document.

Writing OFX or QBO from verified rows

Once the rows balance, most people want them in accounting software. A few things matter more than the rest of the format:

FITID must be unique per account and stable across exports. The OFX spec requires every transaction to carry a financial institution transaction ID, and importers rely on it to skip transactions they have already seen. A random UUID per export means re-importing the same statement creates duplicates. Reusing the same ID for two real, identical transactions (two $2.50 ATM fees on one day, no running balance) means the second one silently disappears. Derive it from the content plus an occurrence counter:

def fitid(account: str, t: Txn, occurrence: int) -> str:
    """A transaction ID that is the same every time you export the same statement."""
    key = f"{account}|{t.day:%Y%m%d}|{t.amount}|{t.balance}|{t.description}|{occurrence}"
    return hashlib.sha256(key.encode()).hexdigest()[:24]


def with_fitids(account: str, txns: list[Txn]) -> list[tuple[str, Txn]]:
    seen: dict[tuple, int] = {}
    out = []
    for t in txns:
        k = (t.day, t.amount, t.balance, t.description)
        seen[k] = seen.get(k, 0) + 1
        out.append((fitid(account, t, seen[k]), t))
    return out


def ofx_stmttrn(fid: str, t: Txn) -> str:
    kind = "CREDIT" if t.amount > 0 else "DEBIT"
    name = re.sub(r"[<>&]", " ", t.description)[:32]
    return (f"<STMTTRN><TRNTYPE>{kind}<DTPOSTED>{t.day:%Y%m%d}"
            f"<TRNAMT>{t.amount}<FITID>{fid}<NAME>{name}</STMTTRN>")
Enter fullscreen mode Exit fullscreen mode

The demo above prints two STMTTRN blocks for the identical fees: same day, same amount, different FITIDs, and the same IDs again every time you export.

Other details that bite:

  • Dates are YYYYMMDD (optionally with time and a timezone offset). Use the posting date column if the statement has both posting and transaction dates, and use the same one everywhere.
  • TRNAMT is signed: negative for money out. TRNTYPE (DEBIT/CREDIT) does not replace the sign.
  • LEDGERBAL should be the statement's closing balance with DTASOF set to the period end, so the importing app can compare it with its own register.
  • OFX 1.x vs 2.x. 1.x is SGML: a plain-text header (OFXHEADER:100, DATA:OFXSGML, ...) and leaf elements with no closing tags. 2.x is XML. Many importers accept both, but don't mix the two styles in one file.
  • Text fields: escape or strip <, > and &; in OFX 1.x NAME is limited to 32 characters, so put the rest of the description in MEMO.
  • Credit cards use CCSTMTRS and CCACCTFROM instead of STMTRS and BANKACCTFROM.
  • QBO is OFX with Intuit-specific additions, notably an INTU.BID value identifying the bank. QuickBooks Desktop checks it against the institutions it knows, which is the usual reason a QBO file that looks perfectly valid is refused.

Checklist

  • Parse money as Decimal, and reject anything that isn't clearly an amount.
  • Decide the thousands/decimal convention once per document.
  • Take the year from the statement period, and test a December–January statement.
  • One sign convention: positive in, negative out. Flip card accounts deliberately.
  • Check opening + transactions = closing, and the summary credit/debit totals if printed.
  • Check the running balance on every row that has one, resyncing after a failure.
  • Strip carried-forward lines and merge wrapped descriptions before checking.
  • Generate stable, unique FITIDs, and write the closing balance into LEDGERBAL.

I work on this problem every day: I build LedgerRows, which converts PDF bank statements to Excel, CSV, QBO and OFX and runs these same opening/closing and running-balance checks before export, flagging the row that doesn't add up. But the checker above is the useful part, and it works with whatever extractor you already have.

What has broken your statement parser? I'd like to hear about the strangest layout you have run into.

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

The invariant-first framing is the right way to teach this. The running-balance check being per-row is what makes failures localizable: it points at the exact row where the parse diverges instead of leaving a period-level delta to hunt down. Worth underlining the Decimal point too: keeping the balance check an equality instead of a tolerance is what keeps real discrepancies from being absorbed into rounding noise once floats drift. The December-January statement being the classic bug tracks with what I've seen, the year-wraparound is where most date handling quietly fails.