DEV Community

Cover image for How does a bank statement become a credit signal?
Swathi Rajagopal
Swathi Rajagopal

Posted on AI-assisted

How does a bank statement become a credit signal?

Turning a messy PDF into clear, explainable signals a lender can trust.

A lender looking at a bank statement isn't really interested in the document. They want answers.

  • Does this person earn regularly?
  • Is this statement genuine?
  • What do they already owe every month?

Getting from a PDF to those answers is a surprisingly interesting engineering problem. This article walks through the main steps, with small Python examples you can adapt.

Why extracting bank statement data is harder than it looks

Imagine trying to read the menu at 50 different restaurants, where every menu puts the prices, dishes and sections in a different place. That's bank statements.

Each bank uses its own layout. Columns move around. Some statements combine debit and credit into one column with a sign, others split them. Narrations are cryptic, like NEFT/CR/ACME PVT LTD/SAL OCT. Tables continue across pages, sometimes repeating headers halfway through.

And that's the clean case. Many statements arrive as scanned copies or phone photos, which need OCR before any of this can start.

So the first goal is simple to state and hard to do: turn every statement, whatever its format, into one consistent structure. For example:

txn = {
    "date": "2026-09-01",      # ISO format
    "narration": "NEFT/CR/ACME PVT LTD/SAL SEP",
    "type": "CR",              # "CR" or "DR"
    "amount": 52000.00,
    "balance": 61850.50,
}
Enter fullscreen mode Exit fullscreen mode

Everything after this step depends on getting it right.

How to check whether a statement has been tampered with

Before analysing anything, it's worth asking whether the numbers can be trusted.

One useful check uses the running balance. In a genuine statement, each row's balance should equal the previous balance plus the credit, or minus the debit. When someone edits an amount to inflate their income, they often forget to fix every balance after it.

def find_balance_breaks(txns, tolerance=0.01):
    """Flag rows where the running balance doesn't add up."""
    breaks = []
    for prev, cur in zip(txns, txns[1:]):
        change = cur["amount"] if cur["type"] == "CR" else -cur["amount"]
        expected = prev["balance"] + change
        if abs(expected - cur["balance"]) > tolerance:
            breaks.append({
                "date": cur["date"],
                "expected_balance": round(expected, 2),
                "stated_balance": cur["balance"],
            })
    return breaks
Enter fullscreen mode Exit fullscreen mode

A break isn't automatic proof of fraud. Missing pages, reversed transactions or extraction errors can cause one too. That's why this should flag a statement for review, not reject it.

Real-world tampering checks go further, for example looking at PDF metadata, font inconsistencies and duplicate transaction IDs. But balance continuity alone catches a surprising amount.

How to detect existing loan EMIs from transactions

Existing EMIs tell a lender how much of a borrower's income is already committed.

EMIs have a recognisable pattern: a debit to the same payee, for roughly the same amount, every month. Here's a simple way to find recurring debits:

import re
from collections import defaultdict
from statistics import mean

def find_recurring_debits(txns, min_months=3, tolerance=0.05):
    """Find debits to the same payee with a similar amount across months."""
    groups = defaultdict(list)
    for t in txns:
        if t["type"] == "DR":
            # Strip reference numbers so the same payee groups together
            payee = re.sub(r"\d+", "", t["narration"]).strip().upper()
            groups[payee].append(t)

    recurring = []
    for payee, items in groups.items():
        months = {t["date"][:7] for t in items}
        amounts = [t["amount"] for t in items]
        avg = mean(amounts)
        similar = all(abs(a - avg) <= tolerance * avg for a in amounts)
        if len(months) >= min_months and similar:
            recurring.append({
                "payee": payee,
                "monthly_amount": round(avg, 2),
                "months_seen": len(months),
            })
    return recurring
Enter fullscreen mode Exit fullscreen mode

This catches EMIs, but also rent, SIPs and subscriptions, which are all recurring too. Telling them apart is where transaction categorization comes in, using narration keywords, known lender names and amount patterns.

How to measure income stability

Two applicants can both earn ₹50,000 a month on average. One gets exactly ₹50,000 every month. The other gets ₹90,000 one month and ₹10,000 the next.

The average hides the difference. A stability measure shows it.

A simple option is to look at monthly income credits and compare how much they vary relative to their average (the coefficient of variation):

from collections import defaultdict
from statistics import mean, pstdev

def income_stability(income_txns):
    """Return a 0-1 score: closer to 1 means steadier monthly income."""
    monthly = defaultdict(float)
    for t in income_txns:
        monthly[t["date"][:7]] += t["amount"]

    values = list(monthly.values())
    if len(values) < 3:
        return None  # not enough history to judge

    variation = pstdev(values) / mean(values)
    return round(max(0.0, 1 - variation), 2)
Enter fullscreen mode Exit fullscreen mode

The code uses pstdev from Python's built-in statistics module.

Here income_txns should contain only genuine income credits, such as salary or business receipts. Including transfers between the borrower's own accounts would distort the result, which is another reason categorization matters.

Why every credit signal needs an explanation

A score with no explanation is hard to trust and harder to defend.

If a credit officer sees "income stability: 0.42," their next question is why. If the system can answer "income dropped sharply in July and August," the officer can ask the borrower about it. Maybe it was a seasonal business, or a job change.

So it's worth designing every signal to carry its evidence:

signal = {
    "name": "existing_emi_load",
    "value": 18500.00,
    "evidence": [
        {"payee": "ABC FINANCE EMI", "monthly_amount": 12500.00, "months_seen": 6},
        {"payee": "XYZ BANK LOAN", "monthly_amount": 6000.00, "months_seen": 6},
    ],
}
Enter fullscreen mode Exit fullscreen mode

This matters beyond good practice. Lending decisions affect people's lives, and regulators increasingly expect lenders to explain them. Signals that point back to specific transactions make that possible.

For the business side of this topic, see Why are Indian lenders looking beyond the credit score?

What the full pipeline looks like

Putting it together, a bank statement analysis pipeline usually runs in this order:

  1. Extract: read the PDF or scan and handle each bank's layout.
  2. Normalise: convert every row into one consistent structure.
  3. Verify: run integrity checks like balance continuity.
  4. Categorise: label transactions as salary, EMI, rent, transfers and so on.
  5. Derive signals: income, stability, obligations, risk flags.
  6. Explain: attach the evidence behind every signal.

Steps 1 and 2 are often the hardest to get right at scale, given how many formats exist. Production tools such as DocuGenie.AI's Bank Statement Analyzer handle that variety across Indian banks, but the core logic of steps 3 to 6 is the same as in the examples above.

Wrap up

A bank statement is a record of how someone actually earns and spends. The engineering challenge is turning that record into signals that are accurate, checkable and explainable.

The examples here are deliberately simple. Real systems add more bank formats, smarter categorisation and stronger fraud checks. But the principle holds: start with clean data, verify it, derive signals, and always show your working.

That last step is what turns a number into something a lender can trust.

Top comments (0)