← ~/work

Case study2026Sole author and maintainer

bankstract

Nigerian bank statements in, reconciled transactions out

Role
Sole author and maintainer
Stack
Python · pdfplumber · PyMuPDF · pydantic
Status
Shipped
Reading
2 min
bankstract · statement → reconciled transactionsengine MIT · cloud AGPL-3.0
inputstatementPDF or XLSX · in memory
detect()which bank?FBN · Opay · PalmPay · Zenith
parse()bank parserDecimal money · typed ParseError
reconcilebalance = prev ± amountmismatch → row index
serializeCSV · JSON
inspect()tamper signals
bankstract.logickoder.devNext.js web
APIFastAPI → engine
edgecaddy-docker-proxyone Hetzner box
accountsBetter Auth · Paystack
Every entry point takes isolate=True: child process, 60s time limit, 2048 MB memory limit on Linux.

Open-source Python engine that turns Nigerian bank statements (PDF and XLSX) into typed, reconciled transactions, plus a hosted SaaS on top of it. Four banks today: First Bank, Opay, PalmPay and Zenith. Parsing stays in memory and nothing touches disk.

Problem

My budgeting app went stale because typing transactions in by hand cost more than the visibility was worth. Every Nigerian bank exports a PDF, every one is laid out differently, and none of them offers an API. Every developer here solves this once, badly, in private. There's no Plaid for Nigeria, so lenders and bookkeeping tools build the same parser in-house.

Approach

One parser module per bank behind a small contract: detect() says whether a file belongs to that bank, parse() returns transactions or raises a typed ParseError. Money is Decimal, never float. auto picks the parser for you, so callers never hard-code a bank.

bash
pip install bankstract

bankstract auto statement.pdf -o transactions.csv
bankstract inspect statement.pdf   # tamper signals as JSON

Design decisions

Reconcile every row

Each row's running balance must equal the previous balance plus or minus its debit or credit. A mismatch raises ReconciliationError with the row index. That one check catches the failure mode that killed my first script: rows silently dropped by a bad page break.

Don't trust a sign

Debit and credit columns carry no sign, so a negative value means the parser misread the layout. It raises LayoutDriftError instead of passing reconciliation and reaching consumers as a clean result. Zero is always written unsigned, never -0.00.

Check the document, not only the numbers

inspect() reports tamper signals: reconciliation, revisions, the producing software, fonts and font sizes, and fills drawn over text, including a cover cut into thin strips.

Isolate hostile files

Every entry point takes isolate=True. The call runs in a child process with a time limit (60s by default) and, on Linux, a memory limit (2048 MB). A worker that runs out of either raises ResourceLimitError, which is deliberately not a ParseError.

The hosted side

bankstract.logickoder.dev runs Next.js and FastAPI on one Hetzner box behind caddy-docker-proxy, with Better Auth and Paystack subscriptions. The engine is MIT and the cloud is AGPL-3.0, so a fintech security review can read the whole path a statement takes.

Outcome

  • On PyPI as bankstract (0.18.3), with parsers for First Bank, Opay, PalmPay and Zenith
  • Live SaaS at bankstract.logickoder.dev
  • Typed error hierarchy, reconciliation, tamper signals and process isolation in the public API

What I'd do next

  • GTB and Wise parsers
  • Batch XLSX import
  • OCR for scanned statements