DEV Community

Damin Mutti for ZeraBooks

Posted on

Wells Fargo statements: using the half-empty 'Ending daily balance' column as a free parser test

Wells Fargo statements print an ending daily balance only on the last transaction of each day, leaving the column blank on other rows. That half-empty column is a free test for a parser: group rows by date, add each day's amounts to a running total, and compare with the printed balance.

A mismatch tells you which day lost or mangled a row, not just that the month is off.

I'm on the ZeraBooks team, where we convert a lot of these. The usual advice is "check that the month ties to the ending balance," which is right but coarse: a month that's off by 425.00 could be off anywhere in 30 days. The daily balance narrows it to one day, and it's sitting in the statement already.

The layout

A Wells Fargo business checking statement typically prints one transaction table with columns for date, check number, description, deposits or credits, withdrawals or debits, and ending daily balance. Credits and debits are separate unsigned columns, and the balance column is filled only on a day's last row. Dates print as month and day; the year comes from the statement period.

Assume your PDF extraction produces this (fictional) October 2026, opening balance 6,215.40, ending balance 7,548.83:

Date,Check Number,Description,Deposits/Credits,Withdrawals/Debits,Ending daily balance
10/1,,Square deposit,1240.00,,7455.40
10/2,1188,Check,,425.00,
10/2,,Gusto payroll ACH,,2880.15,4150.25
10/6,,Mobile deposit,3600.00,,
10/6,,Purchase Home Depot,,186.42,7563.83
10/9,,Wire transfer fee,,15.00,7548.83
Enter fullscreen mode Exit fullscreen mode

Two rows on October 2 and two on October 6 share one balance each. That's the whole point.

The checker

import pandas as pd
from decimal import Decimal

def dec(x):
    return Decimal(str(x)) if pd.notna(x) and str(x).strip() != "" else Decimal("0")

def load(path: str, year: int) -> pd.DataFrame:
    df = pd.read_csv(path, dtype=str, keep_default_na=False)
    df["Date"] = pd.to_datetime(df["Date"] + f"/{year}", format="%m/%d/%Y")
    df["Amount"] = [dec(c) - dec(w) for c, w in zip(df["Deposits/Credits"], df["Withdrawals/Debits"])]
    df["Printed"] = [dec(b) if b.strip() else None for b in df["Ending daily balance"]]
    return df

def check_days(df: pd.DataFrame, opening: Decimal) -> list[str]:
    problems, running = [], opening
    for day, g in df.groupby("Date", sort=True):
        running += sum(g["Amount"], Decimal("0"))
        printed = [p for p in g["Printed"] if p is not None]
        if len(printed) != 1 or g["Printed"].iloc[-1] is None:
            problems.append(f"{day:%Y-%m-%d}: no single ending daily balance on the last row; a row was dropped or split")
            continue
        if running != printed[0]:
            problems.append(f"{day:%Y-%m-%d}: rows give {running}, statement prints {printed[0]}, "
                            f"off by {printed[0] - running}")
            running = printed[0]  # re-anchor so one error doesn't cascade
    return problems

def check_month(df: pd.DataFrame, opening: Decimal, closing: Decimal) -> None:
    total = opening + sum(df["Amount"], Decimal("0"))
    assert total == closing, f"month: {total} != {closing}"
Enter fullscreen mode Exit fullscreen mode

Amounts and balances are read as strings and converted to Decimal, so nothing passes through a float. Two details matter:

  1. The balance must be on the last row of the day. If a day has a balance in the middle and none at the end, rows were reordered or a row was split across lines during extraction. That's flagged separately.
  2. Re-anchoring after a mismatch. Once a day fails, the running total resets to the printed balance. Without that, one dropped row would make every later day fail too, and you'd lose the localization you came for.

Running it

On the clean file:

df = load("wf.csv", 2026)
check_days(df, Decimal("6215.40"))                      # []
check_month(df, Decimal("6215.40"), Decimal("7548.83"))  # passes
Enter fullscreen mode Exit fullscreen mode

Day by day: 6,215.40 plus 1,240.00 is 7,455.40 on October 1. Minus 425.00 and 2,880.15 is 4,150.25 on October 2. Plus 3,600.00 minus 186.42 is 7,563.83 on October 6. Minus 15.00 is 7,548.83 on October 9, the statement's ending balance.

Now simulate the most common extraction failure, a check row that didn't come through because the check number confused the row detection:

check_days(df.drop(index=1), Decimal("6215.40"))
# ['2026-10-02: rows give 4575.25, statement prints 4150.25, off by -425.00']
Enter fullscreen mode Exit fullscreen mode

One line, one day, one amount. Go to October 2 in the PDF and look for a 425.00 debit.

A dropped row that carried the balance looks different:

check_days(df.drop(index=4), Decimal("6215.40"))
# ['2026-10-06: no single ending daily balance on the last row; a row was dropped or split',
#  '2026-10-09: rows give 7735.25, statement prints 7548.83, off by -186.42']
Enter fullscreen mode Exit fullscreen mode

October 6 has no balance on its last remaining row, which is flagged, and since the running total couldn't be re-anchored there, the 186.42 shows up at the next printed balance. Still two adjacent days to look at instead of a whole month.

Other things this catches

  • Credit and debit columns swapped on a page where the header was misread. Every day on that page fails by twice the day's net.
  • A row duplicated across a page break, which happens when a table continues onto the next page with a repeated last line. The day fails by exactly that row's amount.
  • The wrong year on a January statement won't fail this check, because the order of days is unchanged. That needs a separate date-range check against the statement period.

Limits

This relies on the statement printing a daily balance. Many banks don't, and some print one on every row (easy) or never (you're back to the monthly check). Check-image pages at the back of the PDF aren't transactions and shouldn't be parsed into this table. And it can't catch two errors on the same day that cancel out; the deposit count in the statement summary can, if you check it.

Where ZeraBooks Convert fits

ZeraBooks Convert reads Wells Fargo and other bank and card statement PDFs, merges credits and debits into one signed Amount column and proves each statement against its printed balances to the cent, flagging any that don't tie. You download Excel or CSV, including QuickBooks-ready and Xero-ready layouts. It doesn't connect to Wells Fargo or ask for your sign-in. It's part of the ZeraBooks subscription at $79 a month billed annually or $149 monthly, 1,000 pages a month, after a 7-day trial.

Download steps and a converted sample month are in our guide to converting a Wells Fargo bank statement to Excel.

Disclosure: I work at ZeraBooks.

Top comments (0)