Skip to main content

Bank Statement Review with AI

· 5 min read
Bogdan Varlamov
Bogdan Varlamov
Technologist

The Statement Processor reads PDF bank and credit card statements, extracts every transaction, and groups them by vendor. It's finished and on GitHub under an MIT license. Building it surfaced a problem worth writing down: even on a clean, digitally-generated PDF straight from the bank, the table extraction sometimes dropped a row, usually the last one in a transaction table. A sum check that compares the extracted transactions against the total the statement already prints on itself is what made the output trustworthy.

What the tool does

Point it at a folder of PDF statements and it extracts the transactions, normalizes cryptic vendor strings like AMZN MKTP US*2K4X7Y9 into "Amazon," clusters everything by vendor, and writes a CSV summary with per-vendor counts, totals, and date ranges. The original motivation was card replacement: when a card number changes, you need a list of every vendor that has it on file. The project page has the full walkthrough and architecture.

The rest of this post is about the part that isn't in the feature list: making sure the numbers are actually complete.

The extraction problem: dropped table rows

The extractor runs Docling to convert each PDF to markdown and structured tables. Because bank statements are digital PDFs with embedded text, OCR is turned off. Table reconstruction is turned on. In pdf_markdown_extractor.py the pipeline is configured like this:

pipeline_options = PdfPipelineOptions(
...
do_ocr=False,
do_table_structure=True,
)

So the text is sitting right there in the file, no character recognition needed. The hard part is grouping that text back into the correct table grid. Docling's table-structure model does that from detected cell bounding boxes, and on some statements it clipped the final row of a transaction table, even though the row's characters were right there in the PDF.

A single missing transaction is a quiet failure. Nothing errors out. The CSV still looks complete. But every vendor total downstream is now wrong by whatever that dropped charge was, and there's no obvious signal that it happened.

The sanity check: the statement checks itself

A bank statement prints its own total: the sum of purchases or new charges for the period. That makes the extraction self-verifiable, because the transactions I parsed have to add up to that total, or something was missed.

That check lives in validation.py. StatementValidator sums the positive charge amounts and compares them against the expected total pulled from the statement's metadata (fields like purchases, new_charges, or total_charges), using Decimal so currency math stays exact:

# statement_processor/validation.py (trimmed)
transaction_sum = sum(
_to_decimal(tx.amount) for tx in transactions if tx.amount > 0
)
difference = abs(transaction_sum - _to_decimal(expected_total))

if difference > _to_decimal(self._tolerance):
# Parsed total doesn't match the statement's reported total,
# which usually means a transaction was dropped during extraction.
raise ValidationError(statement.source_file, result.errors)
Fail loudly

A mismatch raises a ValidationError and stops in strict mode, so a bad extraction is impossible to miss. The CLI's --no-strict flag downgrades that to a logged warning instead, for batches you want to review afterward.

No AI is involved in this check: it's arithmetic the document already provides. It turns "did I extract everything?" into a question the statement itself answers.

Where AI comes in next

Two directions build on top of reliable extraction, and both have prototype space in the repo's future/ folder.

The first is extraction itself. When a statement format defeats table detection, a vision-language model can read the rendered page directly and return transactions against a Pydantic schema, skipping the brittle bounding-box step entirely. The sum check still applies as the gate: a VLM that hallucinates or misses a line fails the same arithmetic a table parser does, so I can swap extraction engines without lowering the bar for what counts as correct.

The second is review. Once the transaction list is complete and grouped by vendor, a model can classify what's in it:

  • Separate recurring subscriptions from one-off purchases.
  • Flag subscriptions that look forgotten or unused, the kind worth cancelling.
  • Identify charges worth routing through a virtual-card service like Privacy.com, so a single vendor breach or a stealth price hike is contained to one disposable card number.

The same principle that made the extractor usable applies to review: don't trust a total you can't check. Extraction earns trust from the statement's own arithmetic. A review layer has to earn it from transactions that already passed that check.

Code