When you extract transactions from credit card statement PDFs, finding the table is rarely the hard part. The hard part is deciding what each row means: which sign it should carry, whether it's a transaction at all, and whether the rows you kept rebuild the statement's printed previous and new balance. Write that last check first. It catches the bugs every row-level test misses.
Here's a bug that's easy to ship. One issuer prints the monthly payment without a minus sign, because the heading above the block already says "Payments and Credits". A parser that trusts printed signs reads a $1,240.00 payment as a $1,240.00 purchase. Every row looks plausible, every unit test on row parsing passes, and the file is wrong by $2,480.00.
Below is how I'd structure a parser so that bug can't survive. I run ZeraBooks, whose converter does this, so take it as one approach.
Step 1: Extract lines, then classify them
Text extraction from a digital PDF (pdfplumber, pdfminer, or the text layer of an OCR pass for scans) gives you positioned words. Group them into lines by y position, then split each line into date, description and amount by x position.
Don't map lines straight to transactions. Classify them first:
from enum import Enum
class Kind(Enum):
CHARGE = "charge" # purchase, cash advance, balance transfer
PAYMENT = "payment" # money from your bank to the card
CREDIT = "credit" # refund or adjustment
FEE = "fee" # late fee, foreign transaction fee
INTEREST = "interest"
FX_DETAIL = "fx_detail" # "EUR 120.00 @ 1.0842" under a purchase
SUMMARY = "summary" # year-to-date totals, rate table, subtotals
Track the section heading as you walk down the page. A row under a payments heading is never a charge, whatever its sign. A row under an interest heading is interest. A row under a year-to-date heading is a summary and gets dropped.
Step 2: Normalise the sign
Issuers print from the lender's side: a purchase is positive because it adds to the debt. Books are kept from the business's side, where money out is negative. Take the magnitude from the text and let the kind decide the sign:
import re
from decimal import Decimal
def magnitude(text: str) -> Decimal:
# "1,240.00 CR", "-1,240.00" and "(1,240.00)" all give 1240.00
digits = re.sub(r"[^\d.]", "", text)
return Decimal(digits)
def signed_amount(kind: Kind, printed: str) -> Decimal:
m = magnitude(printed)
if kind in (Kind.CHARGE, Kind.FEE, Kind.INTEREST):
return -m
if kind in (Kind.PAYMENT, Kind.CREDIT):
return m
raise ValueError(f"{kind} is not a transaction")
Use Decimal, never float. You're about to compare totals to the cent.
Step 3: Drop what isn't a transaction
Two kinds of line look like transactions and aren't.
Foreign currency detail. A purchase in another currency often prints a second line, something like GBP 40.00 @ 1.2690. The dollar amount is already on the line above. A workable rule: a line with a three-letter currency code and an @ rate, directly under a charge, with no date of its own, is detail.
Summaries. U.S. issuers have to print fee and interest totals for the cycle and the calendar year (Regulation Z, 12 CFR 1026.7(b)(6)), and many add an interest rate table. None of that belongs in the output.
FX = re.compile(r"^[A-Z]{3}\s[\d,]+\.\d{2}\s@\s[\d.]+$")
def is_fx_detail(line, previous_kind) -> bool:
return previous_kind is Kind.CHARGE and line.date is None and bool(FX.match(line.text.strip()))
Step 4: Make the rows tie
Every card statement prints an account summary. Rebuild it from what you kept:
def tie(rows, previous: Decimal, new: Decimal) -> Decimal:
"""Returns the gap. Zero means the rows rebuild the printed summary."""
total = sum((r.amount for r in rows), Decimal("0"))
expected = previous - new # charges negative, payments positive
return expected - total
A worked example with made-up figures. The summary prints a previous balance of 860.00, payments of 860.00, credits of 32.50, purchases of 2,104.17 and a new balance of 2,071.67. The kept rows are four charges (-118.40, -63.25, -1,402.52, -520.00), one payment (+860.00) and one refund (+32.50). They sum to -1,211.67. Previous minus new is 860.00 - 2,071.67 = -1,211.67. Gap zero.
When the gap isn't zero, its size is a strong hint:
def diagnose(gap: Decimal, rows, fx_lines) -> str:
for r in rows:
if gap == -2 * r.amount:
return f"sign flipped on {r.description}"
for r in fx_lines:
if abs(gap) == r.amount:
return f"FX detail kept: {r.text}"
return f"look for a missing line of {abs(gap)}"
Run the payment bug from the top through this and the gap is exactly twice the payment, so it points straight at the row.
Step 5: Check the subtotals too
Business cards print each cardholder's charges under their name, often with a subtotal. The account-level tie can pass while a charge sits under the wrong person. Assign every charge to the cardholder section it was printed in and compare each person's sum with their printed subtotal. Payments, fees and interest belong to the account, not a person.
What ships
ZeraBooks is an AI-native general ledger, bank statement converter and done-for-you bookkeeping service for small businesses and accounting firms. Its converter runs this pipeline: classify, flip signs to the business's side, drop FX detail and summaries, then check each statement against its printed previous and new balance and each cardholder's subtotal before the download is offered. The user-facing version, with the Excel columns you get, is the guide to credit card statement to Excel. Checking and savings statements go through the same bank statement converter.
The tie function is about ten lines. Write it before the parser, not after.













