Insight

How We Know Which Bank Your Statement Comes From

Kittisak

Image

And why the list that decides it is written by hand

Every bank prints its statement differently. Wells Fargo puts money in and money out in two separate columns. Citizens Bank prints one column with a minus sign. Barclays prints the money-out column first. So before Docubrew can pull a single transaction out of a PDF, it has to answer one question: which bank printed this?

Two of those layouts, side by side:

Wells Fargo prints Deposits and Withdrawals as two columns; Citizens Bank prints one Amount column with a minus sign.

Wells Fargo tells you the direction by which column a number sits in. Citizens tells you with a minus sign in a single column. Nothing else on either page says which is which.

Our parser is rule-based, so the wrong bank means the wrong column: deposits come back as withdrawals, in a CSV that looks perfectly normal.

So this post is about the routing step: how Docubrew picks a parser, what keeps two look-alike banks apart, and what happens when none of the 34 match. You may be wondering is why any of this is hand-written when a model would read the statement without it.

The whole decision is one loop

There are 34 bank parsers in convert_pdf_core today, plus a catch-all. They sit in a Python list, in a fixed order, and this loop in router.py walks it:

for parser in PARSER_REGISTRY:
    if any(parser.detect(text) for text in page_texts):
        logger.info(f"Parser matched: {parser.name}")
        return parser.parse(pdf_bytes)
for parser in PARSER_REGISTRY:
    if any(parser.detect(text) for text in page_texts):
        logger.info(f"Parser matched: {parser.name}")
        return parser.parse(pdf_bytes)
for parser in PARSER_REGISTRY:
    if any(parser.detect(text) for text in page_texts):
        logger.info(f"Parser matched: {parser.name}")
        return parser.parse(pdf_bytes)

page_texts holds the text of the first three pages (_MAX_DETECT_PAGES = 3). We look past page one because plenty of statements open with a cover page or a marketing insert, and the bank's name only shows up after it.

That is the entire method. Go down the list, ask each parser "is this yours?", and the first one that says yes gets the file. No scoring, no ranking, no tie-break. Thirty-five checks against three pages of text is too fast to measure. If that list ever reaches 300, it will be slow enough to care about, and I will deal with it then.

The list itself is exactly what it sounds like:

PARSER_REGISTRY: list[BankParser] = [
    WellsFargoCheckingParser(),
    ...
    TD2025ConvenienceCheckingParser(),
    TD2024ConvenienceCheckingParser(),
    ...
    # First Citizens Bank -- before the Citizens Bank parsers: "First Citizens
    # Bank" contains "Citizens Bank" as a substring.
    FirstCitizensParser(),
    CitizensBankParser(),
    ...
    GenericFallbackParser(),
]
PARSER_REGISTRY: list[BankParser] = [
    WellsFargoCheckingParser(),
    ...
    TD2025ConvenienceCheckingParser(),
    TD2024ConvenienceCheckingParser(),
    ...
    # First Citizens Bank -- before the Citizens Bank parsers: "First Citizens
    # Bank" contains "Citizens Bank" as a substring.
    FirstCitizensParser(),
    CitizensBankParser(),
    ...
    GenericFallbackParser(),
]
PARSER_REGISTRY: list[BankParser] = [
    WellsFargoCheckingParser(),
    ...
    TD2025ConvenienceCheckingParser(),
    TD2024ConvenienceCheckingParser(),
    ...
    # First Citizens Bank -- before the Citizens Bank parsers: "First Citizens
    # Bank" contains "Citizens Bank" as a substring.
    FirstCitizensParser(),
    CitizensBankParser(),
    ...
    GenericFallbackParser(),
]

Adding a bank means editing that list and opening a pull request. Someone reads it. That is the point.

Two banks, one name

A parser says "this is mine" by looking for words on the page. Bank statements are printed documents with stable branding, so the words are reliable, and a list of words is something a person can check. A trained model is not.

The catch is that the words overlap. Here is the Citizens Bank check, cut down to the part that matters:

_POSITIVE_MARKERS = ["CITIZENS BANK", "CIRCLE", "MEMBER FDIC", "CIRCLE CHECKING"]
_EXCLUDE_MARKERS  = ["FSP46669", "NCRCP13", "GLOBAL ONE",
                     "MONEY IN (R)", "NAVY FEDERAL", "COMERICA"]

def detect(self, first_page_text: str) -> bool:
    text = first_page_text.upper()
    if any(ex in text for ex in _EXCLUDE_MARKERS):
        return False
    if "CITIZENS BANK" not in text:
        return False
    hits = sum(1 for m in _POSITIVE_MARKERS if m in text)
    return hits >= 2
_POSITIVE_MARKERS = ["CITIZENS BANK", "CIRCLE", "MEMBER FDIC", "CIRCLE CHECKING"]
_EXCLUDE_MARKERS  = ["FSP46669", "NCRCP13", "GLOBAL ONE",
                     "MONEY IN (R)", "NAVY FEDERAL", "COMERICA"]

def detect(self, first_page_text: str) -> bool:
    text = first_page_text.upper()
    if any(ex in text for ex in _EXCLUDE_MARKERS):
        return False
    if "CITIZENS BANK" not in text:
        return False
    hits = sum(1 for m in _POSITIVE_MARKERS if m in text)
    return hits >= 2
_POSITIVE_MARKERS = ["CITIZENS BANK", "CIRCLE", "MEMBER FDIC", "CIRCLE CHECKING"]
_EXCLUDE_MARKERS  = ["FSP46669", "NCRCP13", "GLOBAL ONE",
                     "MONEY IN (R)", "NAVY FEDERAL", "COMERICA"]

def detect(self, first_page_text: str) -> bool:
    text = first_page_text.upper()
    if any(ex in text for ex in _EXCLUDE_MARKERS):
        return False
    if "CITIZENS BANK" not in text:
        return False
    hits = sum(1 for m in _POSITIVE_MARKERS if m in text)
    return hits >= 2

Now look at what a First Citizens Bank statement puts on page one.

Two statement headers. Both say Citizens Bank.

"First Citizens Bank" contains "Citizens Bank". The check above has no idea those are two different banks. On the First Citizens statements in my test files, it survives on a technicality. They hit only one word from the list, and the rule needs two. Any First Citizens statement that also printed "Member FDIC", which most US statements do somewhere, would hit two and get claimed by the wrong parser.

And these two banks lay out their tables nothing alike.

First Citizens uses two amount columns; Citizens uses one signed column.

First Citizens splits money into Deposits/Additions and Withdrawals/Subtractions. Citizens prints a single Amount column where the minus sign carries the direction. A parser built for one, pointed at the other, does not crash. It reads the columns it expects to find, in the positions it expects them, and writes out numbers.

One line of ordering was all that stood between me and that. FirstCitizensParser() sits above CitizensBankParser(), so it gets asked first, and it says yes.

That is a guarantee about position, not about meaning, and writing this post is what pushed me to fix it properly. The obvious patch is to add "FIRST CITIZENS" to the exclude list, and it is wrong: a real Citizens statement with a line like TRANSFER TO FIRST CITIZENS BANK would then reject its own parser. The fix that holds is to ask the other detector, which needs the anchor "FIRST CITIZENS" plus two of nine supporting phrases and so cannot be fooled by a payee name:

if detect_first_citizens(first_page_text):
    return False
if detect_first_citizens(first_page_text):
    return False
if detect_first_citizens(first_page_text):
    return False

The order still stands. It is just no longer the only thing standing.

The same pattern shows up all over the list. The 2025 TD layout is checked before the 2024 one, because the newer check is stricter and the older one is loose. Bank of America credit cards are checked before Bank of America checking, because a credit card statement is also a Bank of America statement, and only one of those parsers knows what a minimum payment is.

The time I let code choose the order

I wrote register_parser() so a parser could be added without editing the file. My first version took a before argument, and when it could not find the parser you named, it quietly stuck yours on the end of the list instead.

The last entry in the list is the catch-all, and the catch-all says yes to every PDF. So "on the end" means "never".

Nothing failed. No error, no warning. The new parser was correct, its own tests passed, and it never ran once on a real file. The fix was to stop being quiet:

target_idx = next(
    (i for i, p in enumerate(PARSER_REGISTRY) if isinstance(p, before)),
    None,
)
if target_idx is None:
    raise ValueError(
        f"register_parser: '{before.__name__}' not found in "
        f"PARSER_REGISTRY; cannot insert before unknown parser"
    )
PARSER_REGISTRY.insert(target_idx, parser)
target_idx = next(
    (i for i, p in enumerate(PARSER_REGISTRY) if isinstance(p, before)),
    None,
)
if target_idx is None:
    raise ValueError(
        f"register_parser: '{before.__name__}' not found in "
        f"PARSER_REGISTRY; cannot insert before unknown parser"
    )
PARSER_REGISTRY.insert(target_idx, parser)
target_idx = next(
    (i for i, p in enumerate(PARSER_REGISTRY) if isinstance(p, before)),
    None,
)
if target_idx is None:
    raise ValueError(
        f"register_parser: '{before.__name__}' not found in "
        f"PARSER_REGISTRY; cannot insert before unknown parser"
    )
PARSER_REGISTRY.insert(target_idx, parser)

That bug is the entire argument against letting the software build the list for itself, and I am the one who wrote it. Plugin systems that scan for installed parsers hand you an order that depends on what got installed, in what sequence, on which machine. When the order is the rule, an order nobody chose is an answer nobody can predict. A list in a file shows up in a diff, and a reviewer can see that your new parser landed under the catch-all.

When we do not know the bank

GenericFallbackParser is last parser in the order. If your bank is not one of the 34, it looks for ruled tables first, and if there are none, it scans the page for date-shaped text with amount-shaped text nearby. In most cases, this works well. But since it does not know your bank's layout, it can sometimes get things wrong. To show you what that looks like, we took a Wells Fargo statement and ran it through the fallback parser and its own parser.

Here is the table we are about to hand it, five rows off page 2 of a Wells Fargo statement.

Wells Fargo transaction table: Deposits/Additions and Withdrawals/Subtractions are separate columns, and not one amount carries a sign.

Read by the Wells Fargo parser:

Date,Check Number,Description,Deposits,Withdrawals,Balance
09/09/2025,,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
09/09/2025,,Online Transfer From <redacted> Way2Save Checking,500.00,,
09/09/2025,,Wire Trans Svc Charge - Sequence: <redacted>,,15.00,8694.01
09/11/2025,,Withdrawal Made In A Branch/Store,,8000.00,
09/11/2025,,Zelle to <redacted> on 09/11 Ref #<redacted>

Date,Check Number,Description,Deposits,Withdrawals,Balance
09/09/2025,,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
09/09/2025,,Online Transfer From <redacted> Way2Save Checking,500.00,,
09/09/2025,,Wire Trans Svc Charge - Sequence: <redacted>,,15.00,8694.01
09/11/2025,,Withdrawal Made In A Branch/Store,,8000.00,
09/11/2025,,Zelle to <redacted> on 09/11 Ref #<redacted>

Date,Check Number,Description,Deposits,Withdrawals,Balance
09/09/2025,,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
09/09/2025,,Online Transfer From <redacted> Way2Save Checking,500.00,,
09/09/2025,,Wire Trans Svc Charge - Sequence: <redacted>,,15.00,8694.01
09/11/2025,,Withdrawal Made In A Branch/Store,,8000.00,
09/11/2025,,Zelle to <redacted> on 09/11 Ref #<redacted>

And the same five rows of the same PDF, read by the catch-all:

Date,Description,Deposits,Withdrawals,Balance
9/9,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
9/9,Online Transfer From <redacted> Way2Save Checking,500.00,,
9/9,Wire Trans Svc Charge - Sequence: <redacted>,15.00,,8694.01
9/11,Withdrawal Made In A Branch/Store,8000.00,,
9/11,Zelle to <redacted> on Ref #<redacted>

Date,Description,Deposits,Withdrawals,Balance
9/9,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
9/9,Online Transfer From <redacted> Way2Save Checking,500.00,,
9/9,Wire Trans Svc Charge - Sequence: <redacted>,15.00,,8694.01
9/11,Withdrawal Made In A Branch/Store,8000.00,,
9/11,Zelle to <redacted> on Ref #<redacted>

Date,Description,Deposits,Withdrawals,Balance
9/9,"WT Fed#05809 Td Bank, NA <redacted>",8200.00,,
9/9,Online Transfer From <redacted> Way2Save Checking,500.00,,
9/9,Wire Trans Svc Charge - Sequence: <redacted>,15.00,,8694.01
9/11,Withdrawal Made In A Branch/Store,8000.00,,
9/11,Zelle to <redacted> on Ref #<redacted>

It found the right rows, but only the two deposits were classified correctly. All three withdrawals were treated as deposits.

The reason is simple: this statement uses columns, not minus signs, to tell deposits from withdrawals. The fallback parser does not understand those column headers. It only sees the amount, so a negative number becomes a Withdrawal, while everything else becomes a Deposit. Even a row with “Withdrawal” in its description can therefore be classified as a deposit.

Just to let you know, behind every parsed row, we also keep a confidence score based on how much evidence supports the result. For statements we do not recognize, the fallback parser intentionally keeps this confidence lower. This does not mean the result is wrong, we are simply being cautious. The score stays internal to avoid confusing users and it also helps us see which bank need their own parser next.

Currently, we are exploring a more practical solution: letting you mark the transaction area, columns, and headers directly in the PDF viewer. This could turn an unrecognized statement into a format you define clearly.

Until then, if a statement does not convert correctly, use the Report feature in the app and we will convert it for you for free.

Why not point a model at it

The obvious question is why I wrote 34 hand-written parsers at all, when an AI model would read any statement without a single one of them. Two reasons: I need the same PDF to produce the same CSV every time, down to the byte, and I need a missing amount to stay blank rather than be guessed at. A model gives me neither, and we mentioned about this topic in ChatGPT vs a bank statement converter.

That is not a permanent no. I'm currently exploring different methods at recognizing a bank nobody has written a specific parser for with privacy in mind. I'll share more details once it's more clear. Stay tuned!