A business card statement has two kinds of numbers: transactions, and totals of transactions. A parser that doesn't know the difference double counts. The fix is to tag every extracted line with the section it came from, whitelist the sections that hold transactions, and then prove two things: the statement against its new balance, and each cardholder's rows against their printed subtotal.
I work on ZeraBooks Convert, and this is a simplified version of how we keep summary amounts out of a card statement's rows. Capital One's own guide names a year-end summary and an interest calculation section on its card statements; both print amounts that look exactly like charges.
The code
from collections import defaultdict
from decimal import Decimal as D
# Lines pulled off a business card statement, each tagged with the section it came from.
lines = [
("Payments and credits", None, "Payment received", D("-4210.75")),
("Transactions", "M. OKAFOR", "Office supply store", D("312.40")),
("Transactions", "M. OKAFOR", "Airline", D("689.00")),
("Transactions", "J. PARK", "Hardware store", D("1145.18")),
("Transactions", "J. PARK", "Fuel", D("96.22")),
("Transactions", "J. PARK", "Refund, hardware store", D("-58.00")),
("Transactions", "L. DIAZ", "Software subscription", D("49.99")),
("Fees", None, "Late fee", D("0.00")),
("Interest", None, "Interest charge", D("0.00")),
("Year-end summary", None, "Total fees this year", D("39.00")),
("Year-end summary", None, "Total interest this year", D("112.64")),
]
printed = {"previous": D("4210.75"), "new": D("2234.79"),
"cardholder_totals": {"M. OKAFOR": D("1001.40"), "J. PARK": D("1183.40"), "L. DIAZ": D("49.99")}}
ROW_SECTIONS = {"Payments and credits", "Transactions", "Fees", "Interest"}
rows = [l for l in lines if l[0] in ROW_SECTIONS]
skipped = [l for l in lines if l[0] not in ROW_SECTIONS]
net = sum(a for *_, a in rows)
print(f"previous {printed['previous']:,.2f} + activity {net:,.2f} = {printed['previous'] + net:,.2f}; printed new balance {printed['new']:,.2f}")
by_holder = defaultdict(D)
for section, holder, desc, amount in rows:
if holder:
by_holder[holder] += amount
for holder, total in sorted(by_holder.items()):
want = printed["cardholder_totals"][holder]
print(f"{holder:10} {total:>9,.2f} printed {want:>9,.2f} {'ok' if total == want else 'CHECK'}")
print(f"left out: {len(skipped)} summary lines worth {sum(a for *_, a in skipped):,.2f}")
Everything here is made up. Amounts follow the card company's view: a charge increases what you owe, so it's positive; payments and refunds are negative.
Output
Python 3.14:
previous 4,210.75 + activity -1,975.96 = 2,234.79; printed new balance 2,234.79
J. PARK 1,183.40 printed 1,183.40 ok
L. DIAZ 49.99 printed 49.99 ok
M. OKAFOR 1,001.40 printed 1,001.40 ok
left out: 2 summary lines worth 151.64
The statement ties and every cardholder ties. If the parser had kept the year-end summary lines, the activity would have been 151.64 higher and the computed new balance would have been 2,386.43 against a printed 2,234.79. The proof would fail, and the gap would be exactly the amount of the summary lines.
Why a whitelist and not a blacklist
You could skip sections you know are summaries. But card issuers add sections, rename them and move them. A new "Rewards summary" with a dollar figure in it would sail through a blacklist. A whitelist fails safe: an unknown section is left out, the balance proof breaks if it mattered, and a person looks.
Why two proofs
The statement-level proof catches a missing or extra line anywhere. It doesn't catch a line assigned to the wrong person, because that doesn't change the total. The per-cardholder proof does. In this example, if the 58.00 refund had been attached to M. OKAFOR instead of J. PARK, the statement would still tie and both of their subtotals would fail by 58.00, in opposite directions. That's the fingerprint of a misassigned line.
Sign conventions
This code works in the issuer's convention, where charges are positive because they increase the balance owed. A spreadsheet for bookkeeping usually wants the opposite: money out negative, money back positive. Convert once, at the export boundary, and keep the proof in whichever convention the statement prints. Mixing the two mid-pipeline is how refunds end up doubled.
In the product
ZeraBooks Convert reads Capital One card statements this way: rows from transaction sections only, a Type on each row (Charge, Payment, Credit, Fee, Interest), the statement proven against its new balance, cardholder totals checked where printed, and an option to download one file per cardholder. A worked Spark statement is on the ZeraBooks Capital One statement to Excel page.
Disclosure: I'm the founder of ZeraBooks; the bank statement converter is our product.













