From 0f6e4126e8beece7c2bda13d8631cb064ae2f083 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Tue, 4 Aug 2026 13:50:52 +0530 Subject: [PATCH] Add independent bank identity detection and APGB parser --- .../parsers/andhra_pradesh_grameena_bank.py | 165 ++++++++++++++ .../parsers/bank_identity.py | 212 ++++++++++++++++++ .../parsers/registry.py | 146 ++++-------- 3 files changed, 420 insertions(+), 103 deletions(-) create mode 100644 app/modules/bank_statement_analyzer/parsers/andhra_pradesh_grameena_bank.py create mode 100644 app/modules/bank_statement_analyzer/parsers/bank_identity.py diff --git a/app/modules/bank_statement_analyzer/parsers/andhra_pradesh_grameena_bank.py b/app/modules/bank_statement_analyzer/parsers/andhra_pradesh_grameena_bank.py new file mode 100644 index 0000000..ee52718 --- /dev/null +++ b/app/modules/bank_statement_analyzer/parsers/andhra_pradesh_grameena_bank.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from pathlib import Path +import re + +import pandas as pd + +from .base import BaseParser, StatementMeta, amount, extract_text, finalize, norm +from .common import date_iso, find, infer_mode + + +class AndhraPradeshGrameenaBankParser(BaseParser): + bank_name = "Andhra Pradesh Grameena Bank" + parser_name = "AndhraPradeshGrameenaBankParser" + + _DATE_LINE = re.compile(r"^\s*(\d{2}-\d{2}-\d{4})\s+(.*)$") + _BALANCE = re.compile(r"(-?[0-9,]+\.\d{2})\s*(Cr|Dr)\s*$", re.I) + _MONEY = re.compile(r"(? float: + upper = (text or "").upper() + score = 0.0 + if "ANDHRA PRADESH GRAMEENA BANK" in upper: + score += 0.78 + if "COREBANKING.APGB.IN" in upper or "APGB.IN" in upper: + score += 0.14 + if "UBIN0CG" in upper: + score += 0.06 + if "STATEMENT OF ACCOUNT" in upper and "PAGE TOTAL" in upper and "GRAND TOTAL" in upper: + score += 0.02 + return min(score, 0.999) + + @staticmethod + def _signed_balance(value: str, marker: str) -> float | None: + parsed = amount(value) + if parsed is None: + return None + return -abs(parsed) if marker.upper() == "DR" else abs(parsed) + + @staticmethod + def _reference(narration: str) -> str: + for pattern in ( + r"\bCMP\d{12,}\b", + r"\bNACH[A-Z0-9/\-]+\b", + r"\b\d{12,18}\b", + ): + match = re.search(pattern, narration, re.I) + if match: + return match.group(0) + return "" + + def parse(self, path, text=None): + pdf_path = Path(path) + text = text or extract_text(pdf_path) + meta = StatementMeta( + bank_name=self.bank_name, + source_file=pdf_path.name, + parser_name=self.parser_name, + confidence="High", + ) + + meta.customer_name = norm(find(r"Name\s*:\s*([^\n]+?)(?:\s+A/C\s+NO|$)", text, flags=re.I)) + meta.account_number = find(r"A/C\s*NO\s*:\s*([0-9]+)", text, flags=re.I) + meta.customer_id = find(r"Cust\s*ID\s*:\s*([0-9]+)", text, flags=re.I) + meta.ifsc = find(r"IFSC\s*CODE\s*:\s*([A-Z0-9]+)", text, flags=re.I) + period = re.search(r"STATEMENT\s+OF\s+ACCOUNT\s+(\d{2}-\d{2}-\d{4})\s+to\s+(\d{2}-\d{2}-\d{4})", text, re.I) + if period: + meta.period_from = date_iso(period.group(1)) + meta.period_to = date_iso(period.group(2)) + + bf = re.search(r"\bB/F\s+([0-9,]+\.\d{2})\s*(Cr|Dr)", text, re.I) + if bf: + meta.opening_balance = self._signed_balance(bf.group(1), bf.group(2)) + + grand_total = re.search( + r"Grand\s+Total\s*:\s*([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s+([0-9,]+\.\d{2})\s*(Cr|Dr)", + text, + re.I, + ) + if grand_total: + meta.total_debit = amount(grand_total.group(1)) + meta.total_credit = amount(grand_total.group(2)) + meta.closing_balance = self._signed_balance(grand_total.group(3), grand_total.group(4)) + + rows: list[dict] = [] + current: dict | None = None + previous_balance = meta.opening_balance + + for page_no, page_text in enumerate(re.split(r"\f", text), start=1): + for raw_line in page_text.splitlines(): + stripped = raw_line.strip() + match = self._DATE_LINE.match(stripped) + if match: + if current is not None: + rows.append(current) + previous_balance = current["balance"] + current = None + + txn_date, remainder = match.groups() + balance_match = self._BALANCE.search(remainder) + if not balance_match: + continue + balance_value = self._signed_balance(balance_match.group(1), balance_match.group(2)) + before_balance = remainder[:balance_match.start()].rstrip() + money_matches = list(self._MONEY.finditer(before_balance)) + narration_end = money_matches[0].start() if money_matches else len(before_balance) + narration = norm(before_balance[:narration_end]) + + debit = credit = None + if previous_balance is not None and balance_value is not None: + delta = round(balance_value - previous_balance, 2) + if delta < -0.009: + debit = abs(delta) + elif delta > 0.009: + credit = delta + elif money_matches: + # The statement prints debit before credit. The common + # running-balance validator will independently verify it. + debit = amount(money_matches[-1].group(1)) + + current = { + "transaction_date": txn_date, + "value_date": txn_date, + "narration": narration, + "reference_no": self._reference(narration), + "debit": debit, + "credit": credit, + "balance": balance_value, + "source_page": page_no, + "mode": infer_mode(narration), + } + continue + + if current is not None: + continuation = norm(stripped) + upper = continuation.upper() + if not continuation: + continue + if ( + upper.startswith(( + "PAGE TOTAL", "GRAND TOTAL", "TRANSACTION DETAILS", "HTTPS://", + "UNLESS THE", "BRANCH MANAGER", "****END", "MUCH MORE", + )) + or set(continuation) <= {"-"} + or "STATEMENT OF ACCOUNT,IT WILL BE TAKEN" in upper + ): + continue + current["narration"] = norm(f"{current['narration']} {continuation}") + current["reference_no"] = current["reference_no"] or self._reference(current["narration"]) + + # Do not carry an unfinished row into footer text on another page; + # append it at the page boundary while preserving its source page. + if current is not None: + rows.append(current) + previous_balance = current["balance"] + current = None + + df = pd.DataFrame(rows) + result = finalize(df, meta) + if result.empty: + raise ValueError( + "Andhra Pradesh Grameena Bank format was identified, but no transaction rows could be extracted." + ) + return meta, result diff --git a/app/modules/bank_statement_analyzer/parsers/bank_identity.py b/app/modules/bank_statement_analyzer/parsers/bank_identity.py new file mode 100644 index 0000000..7a69715 --- /dev/null +++ b/app/modules/bank_statement_analyzer/parsers/bank_identity.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +from typing import Iterable + + +@dataclass(frozen=True) +class BankIdentity: + name: str + category: str + aliases: tuple[str, ...] = () + ifsc_prefixes: tuple[str, ...] = () + domains: tuple[str, ...] = () + + +@dataclass(frozen=True) +class BankIdentityMatch: + name: str + category: str + confidence: float + evidence: tuple[str, ...] + + +# Commercial-bank identity registry. Parsing is intentionally independent of +# this list: templates extract transactions; this registry only assigns the +# institution name. The list covers current public/private banks, SFBs, +# payments banks, RRBs after the 2025 One-State-One-RRB amalgamation, major +# foreign banks operating in India, and legacy names that still appear in PDFs. +BANK_IDENTITIES: tuple[BankIdentity, ...] = ( + # Public sector banks + BankIdentity("State Bank of India", "Public Sector Bank", ("STATE BANK OF INDIA", "SBI"), ("SBIN",), ("sbi.co.in", "onlinesbi.sbi")), + BankIdentity("Bank of Baroda", "Public Sector Bank", ("BANK OF BARODA", "BOB"), ("BARB",), ("bankofbaroda.in",)), + BankIdentity("Bank of India", "Public Sector Bank", ("BANK OF INDIA",), ("BKID",), ("bankofindia.co.in",)), + BankIdentity("Bank of Maharashtra", "Public Sector Bank", ("BANK OF MAHARASHTRA",), ("MAHB",), ("bankofmaharashtra.in",)), + BankIdentity("Canara Bank", "Public Sector Bank", ("CANARA BANK",), ("CNRB",), ("canarabank.com",)), + BankIdentity("Central Bank of India", "Public Sector Bank", ("CENTRAL BANK OF INDIA",), ("CBIN",), ("centralbankofindia.co.in",)), + BankIdentity("Indian Bank", "Public Sector Bank", ("INDIAN BANK",), ("IDIB",), ("indianbank.in",)), + BankIdentity("Indian Overseas Bank", "Public Sector Bank", ("INDIAN OVERSEAS BANK", "IOB"), ("IOBA",), ("iob.in",)), + BankIdentity("Punjab & Sind Bank", "Public Sector Bank", ("PUNJAB & SIND BANK", "PUNJAB AND SIND BANK"), ("PSIB",), ("punjabandsindbank.co.in",)), + BankIdentity("Punjab National Bank", "Public Sector Bank", ("PUNJAB NATIONAL BANK", "PNB"), ("PUNB",), ("pnbindia.in",)), + BankIdentity("UCO Bank", "Public Sector Bank", ("UCO BANK",), ("UCBA",), ("ucobank.com",)), + BankIdentity("Union Bank of India", "Public Sector Bank", ("UNION BANK OF INDIA",), ("UBIN",), ("unionbankofindia.co.in",)), + + # Indian private sector banks + BankIdentity("Axis Bank", "Private Sector Bank", ("AXIS BANK",), ("UTIB",), ("axisbank.com",)), + BankIdentity("Bandhan Bank", "Private Sector Bank", ("BANDHAN BANK",), ("BDBL",), ("bandhanbank.com",)), + BankIdentity("CSB Bank", "Private Sector Bank", ("CSB BANK", "CATHOLIC SYRIAN BANK"), ("CSBK",), ("csb.co.in",)), + BankIdentity("City Union Bank", "Private Sector Bank", ("CITY UNION BANK",), ("CIUB",), ("cityunionbank.com",)), + BankIdentity("DCB Bank", "Private Sector Bank", ("DCB BANK", "DEVELOPMENT CREDIT BANK"), ("DCBL",), ("dcbbank.com",)), + BankIdentity("Dhanlaxmi Bank", "Private Sector Bank", ("DHANLAXMI BANK", "DHANALAKSHMI BANK"), ("DLXB",), ("dhanbank.com",)), + BankIdentity("Federal Bank", "Private Sector Bank", ("FEDERAL BANK",), ("FDRL",), ("federalbank.co.in",)), + BankIdentity("HDFC Bank", "Private Sector Bank", ("HDFC BANK",), ("HDFC",), ("hdfcbank.com",)), + BankIdentity("ICICI Bank", "Private Sector Bank", ("ICICI BANK",), ("ICIC",), ("icicibank.com",)), + BankIdentity("IDBI Bank", "Private Sector Bank", ("IDBI BANK",), ("IBKL",), ("idbibank.in",)), + BankIdentity("IDFC FIRST Bank", "Private Sector Bank", ("IDFC FIRST BANK", "IDFC BANK"), ("IDFB",), ("idfcfirstbank.com",)), + BankIdentity("IndusInd Bank", "Private Sector Bank", ("INDUSIND BANK",), ("INDB",), ("indusind.com",)), + BankIdentity("Jammu & Kashmir Bank", "Private Sector Bank", ("JAMMU & KASHMIR BANK", "JAMMU AND KASHMIR BANK", "J&K BANK"), ("JAKA",), ("jkb.bank.in", "jkbank.com")), + BankIdentity("Karnataka Bank", "Private Sector Bank", ("KARNATAKA BANK",), ("KARB",), ("karnatakabank.com",)), + BankIdentity("Karur Vysya Bank", "Private Sector Bank", ("KARUR VYSYA BANK", "KVB"), ("KVBL",), ("kvb.co.in",)), + BankIdentity("Kotak Mahindra Bank", "Private Sector Bank", ("KOTAK MAHINDRA BANK", "KOTAK BANK"), ("KKBK",), ("kotak.com",)), + BankIdentity("Nainital Bank", "Private Sector Bank", ("NAINITAL BANK",), ("NTBL",), ("nainitalbank.co.in",)), + BankIdentity("RBL Bank", "Private Sector Bank", ("RBL BANK", "RATNAKAR BANK"), ("RATN",), ("rblbank.com",)), + BankIdentity("South Indian Bank", "Private Sector Bank", ("SOUTH INDIAN BANK",), ("SIBL",), ("southindianbank.com",)), + BankIdentity("Tamilnad Mercantile Bank", "Private Sector Bank", ("TAMILNAD MERCANTILE BANK", "TMB"), ("TMBL",), ("tmb.in",)), + BankIdentity("YES Bank", "Private Sector Bank", ("YES BANK",), ("YESB",), ("yesbank.in",)), + + # Small finance banks + BankIdentity("AU Small Finance Bank", "Small Finance Bank", ("AU SMALL FINANCE BANK",), ("AUBL",), ("aubank.in",)), + BankIdentity("Capital Small Finance Bank", "Small Finance Bank", ("CAPITAL SMALL FINANCE BANK",), ("CLBL",), ("capitalbank.co.in",)), + BankIdentity("Equitas Small Finance Bank", "Small Finance Bank", ("EQUITAS SMALL FINANCE BANK",), ("ESFB",), ("equitasbank.com",)), + BankIdentity("ESAF Small Finance Bank", "Small Finance Bank", ("ESAF SMALL FINANCE BANK",), ("ESMF",), ("esafbank.com",)), + BankIdentity("Jana Small Finance Bank", "Small Finance Bank", ("JANA SMALL FINANCE BANK",), ("JSFB",), ("janabank.com",)), + BankIdentity("Shivalik Small Finance Bank", "Small Finance Bank", ("SHIVALIK SMALL FINANCE BANK",), ("SMCB",), ("shivalikbank.com",)), + BankIdentity("slice Small Finance Bank", "Small Finance Bank", ("SLICE SMALL FINANCE BANK", "NORTH EAST SMALL FINANCE BANK"), ("NESF",), ("slice.bank",)), + BankIdentity("Suryoday Small Finance Bank", "Small Finance Bank", ("SURYODAY SMALL FINANCE BANK",), ("SURY",), ("suryodaybank.com",)), + BankIdentity("Ujjivan Small Finance Bank", "Small Finance Bank", ("UJJIVAN SMALL FINANCE BANK",), ("UJVN",), ("ujjivansfb.in",)), + BankIdentity("Unity Small Finance Bank", "Small Finance Bank", ("UNITY SMALL FINANCE BANK",), ("UNSF",), ("theunitybank.com",)), + BankIdentity("Utkarsh Small Finance Bank", "Small Finance Bank", ("UTKARSH SMALL FINANCE BANK",), ("UTKS",), ("utkarsh.bank",)), + + # Payments banks + BankIdentity("Airtel Payments Bank", "Payments Bank", ("AIRTEL PAYMENTS BANK",), ("AIRP",), ("airtel.in/bank",)), + BankIdentity("Fino Payments Bank", "Payments Bank", ("FINO PAYMENTS BANK",), ("FINO",), ("finobank.com",)), + BankIdentity("India Post Payments Bank", "Payments Bank", ("INDIA POST PAYMENTS BANK", "IPPB"), ("IPOS",), ("ippbonline.com",)), + BankIdentity("Jio Payments Bank", "Payments Bank", ("JIO PAYMENTS BANK",), ("JIOP",), ("jiopaymentsbank.com",)), + BankIdentity("NSDL Payments Bank", "Payments Bank", ("NSDL PAYMENTS BANK",), ("NSPB",), ("nsdlpayments.bank",)), + BankIdentity("Paytm Payments Bank", "Payments Bank", ("PAYTM PAYMENTS BANK",), ("PYTM",), ("paytmbank.com",)), + + # Regional Rural Banks after One-State-One-RRB (2025) + BankIdentity("Andhra Pradesh Grameena Bank", "Regional Rural Bank", ("ANDHRA PRADESH GRAMEENA BANK", "ANDHRA PRAGATHI GRAMEENA BANK", "CHAITANYA GODAVARI GRAMEENA BANK", "SAPTAGIRI GRAMEENA BANK"), ("UBIN0CG",), ("corebanking.apgb.in", "apgb.in")), + BankIdentity("Arunachal Pradesh Rural Bank", "Regional Rural Bank", ("ARUNACHAL PRADESH RURAL BANK",), (), ("apruralbank.com",)), + BankIdentity("Assam Gramin Bank", "Regional Rural Bank", ("ASSAM GRAMIN BANK", "ASSAM GRAMIN VIKASH BANK"), (), ("agvbank.co.in",)), + BankIdentity("Bihar Gramin Bank", "Regional Rural Bank", ("BIHAR GRAMIN BANK", "DAKSHIN BIHAR GRAMIN BANK", "UTTAR BIHAR GRAMIN BANK"), (), ("dbgb.in", "ubgb.in")), + BankIdentity("Chhattisgarh Gramin Bank", "Regional Rural Bank", ("CHHATTISGARH GRAMIN BANK", "CHHATTISGARH RAJYA GRAMIN BANK"), (), ("cgbank.in",)), + BankIdentity("Gujarat Gramin Bank", "Regional Rural Bank", ("GUJARAT GRAMIN BANK", "BARODA GUJARAT GRAMIN BANK", "SAURASHTRA GRAMIN BANK"), (), ("bggb.in", "sgbrrb.org")), + BankIdentity("Haryana Gramin Bank", "Regional Rural Bank", ("HARYANA GRAMIN BANK", "SARVA HARYANA GRAMIN BANK"), (), ("shgb.co.in",)), + BankIdentity("Himachal Pradesh Gramin Bank", "Regional Rural Bank", ("HIMACHAL PRADESH GRAMIN BANK",), (), ("hpgb.in",)), + BankIdentity("Jammu and Kashmir Grameen Bank", "Regional Rural Bank", ("JAMMU AND KASHMIR GRAMEEN BANK", "J&K GRAMEEN BANK", "ELLAQUAI DEHATI BANK"), (), ("jkgb.in",)), + BankIdentity("Jharkhand Rajya Gramin Bank", "Regional Rural Bank", ("JHARKHAND RAJYA GRAMIN BANK",), (), ("jrgb.in",)), + BankIdentity("Karnataka Grameena Bank", "Regional Rural Bank", ("KARNATAKA GRAMEENA BANK", "KARNATAKA GRAMIN BANK", "KARNATAKA VIKAS GRAMEENA BANK"), (), ("karnatakagraminbank.com", "kvgbank.com")), + BankIdentity("Kerala Gramin Bank", "Regional Rural Bank", ("KERALA GRAMIN BANK",), (), ("keralagbank.com",)), + BankIdentity("Madhya Pradesh Gramin Bank", "Regional Rural Bank", ("MADHYA PRADESH GRAMIN BANK", "MADHYANCHAL GRAMIN BANK"), (), ("mpgb.co.in",)), + BankIdentity("Maharashtra Gramin Bank", "Regional Rural Bank", ("MAHARASHTRA GRAMIN BANK", "VIDHARBHA KONKAN GRAMIN BANK"), (), ("mahagramin.in", "vkgb.co.in")), + BankIdentity("Manipur Rural Bank", "Regional Rural Bank", ("MANIPUR RURAL BANK",), (), ("manipurruralbank.com",)), + BankIdentity("Meghalaya Rural Bank", "Regional Rural Bank", ("MEGHALAYA RURAL BANK",), (), ("meghalayaruralbank.co.in",)), + BankIdentity("Mizoram Rural Bank", "Regional Rural Bank", ("MIZORAM RURAL BANK",), (), ("mizoramruralbank.in",)), + BankIdentity("Nagaland Rural Bank", "Regional Rural Bank", ("NAGALAND RURAL BANK",), (), ("nagalandruralbank.com",)), + BankIdentity("Odisha Grameen Bank", "Regional Rural Bank", ("ODISHA GRAMEEN BANK", "ODISHA GRAMYA BANK", "UTKAL GRAMEEN BANK"), (), ("odishabank.in", "odishagramyabank.in")), + BankIdentity("Puducherry Grama Bank", "Regional Rural Bank", ("PUDUCHERRY GRAMA BANK", "PUDUVAI BHARATHIAR GRAMA BANK"), (), ("puduvaibharathiargramabank.in",)), + BankIdentity("Punjab Gramin Bank", "Regional Rural Bank", ("PUNJAB GRAMIN BANK",), (), ("pgb.org.in",)), + BankIdentity("Rajasthan Gramin Bank", "Regional Rural Bank", ("RAJASTHAN GRAMIN BANK", "RAJASTHAN MARUDHARA GRAMIN BANK", "BARODA RAJASTHAN KSHETRIYA GRAMIN BANK"), (), ("rmgb.in", "brkgb.com")), + BankIdentity("Tamil Nadu Grama Bank", "Regional Rural Bank", ("TAMIL NADU GRAMA BANK",), (), ("tamilnadugramabank.com",)), + BankIdentity("Telangana Grameena Bank", "Regional Rural Bank", ("TELANGANA GRAMEENA BANK", "ANDHRA PRADESH GRAMEENA VIKAS BANK"), (), ("tgbhyd.in", "apgvbank.in")), + BankIdentity("Tripura Gramin Bank", "Regional Rural Bank", ("TRIPURA GRAMIN BANK",), (), ("tripuragraminbank.org",)), + BankIdentity("Uttar Pradesh Gramin Bank", "Regional Rural Bank", ("UTTAR PRADESH GRAMIN BANK", "ARYAVART BANK", "BARODA U.P. BANK", "BARODA UP BANK", "PRATHAMA U.P. GRAMIN BANK"), (), ("upgbank.com", "aryavart-rrb.com", "barodaupbank.in", "prathamaupbank.com")), + BankIdentity("Uttarakhand Gramin Bank", "Regional Rural Bank", ("UTTARAKHAND GRAMIN BANK",), (), ("uttarakhandgraminbank.com",)), + BankIdentity("West Bengal Gramin Bank", "Regional Rural Bank", ("WEST BENGAL GRAMIN BANK", "BANGIYA GRAMIN VIKASH BANK", "PASCHIM BANGA GRAMIN BANK", "UTTARBANGA KSHETRIYA GRAMIN BANK"), (), ("bgvb.in", "pbgbank.com", "ubkgb.org")), + + # Foreign banks commonly issuing Indian account statements + BankIdentity("HSBC India", "Foreign Bank", ("HSBC BANK", "THE HONGKONG AND SHANGHAI BANKING CORPORATION"), ("HSBC",), ("hsbc.co.in",)), + BankIdentity("Standard Chartered Bank India", "Foreign Bank", ("STANDARD CHARTERED BANK",), ("SCBL",), ("sc.com/in",)), + BankIdentity("DBS Bank India", "Foreign Bank", ("DBS BANK INDIA", "DBS BANK"), ("DBSS",), ("dbs.com/in",)), + BankIdentity("Deutsche Bank India", "Foreign Bank", ("DEUTSCHE BANK",), ("DEUT",), ("deutschebank.co.in",)), + BankIdentity("Citibank India", "Foreign Bank", ("CITIBANK", "CITI BANK"), ("CITI",), ("citibank.co.in",)), + BankIdentity("Bank of America India", "Foreign Bank", ("BANK OF AMERICA",), ("BOFA",), ("bankofamerica.com",)), + BankIdentity("Barclays Bank India", "Foreign Bank", ("BARCLAYS BANK",), ("BARC",), ("barclays.in",)), + BankIdentity("BNP Paribas India", "Foreign Bank", ("BNP PARIBAS",), ("BNPA",), ("apac.bnpparibas",)), + BankIdentity("MUFG Bank India", "Foreign Bank", ("MUFG BANK", "BANK OF TOKYO-MITSUBISHI UFJ"), ("BOTM",), ("bk.mufg.jp",)), + BankIdentity("Mizuho Bank India", "Foreign Bank", ("MIZUHO BANK",), ("MHCB",), ("mizuhogroup.com",)), + BankIdentity("Sumitomo Mitsui Banking Corporation India", "Foreign Bank", ("SUMITOMO MITSUI BANKING CORPORATION", "SMBC"), ("SMBC",), ("smbc.co.jp",)), +) + + +def _normalise(value: str) -> str: + return re.sub(r"[^A-Z0-9.&]+", " ", str(value or "").upper()).strip() + + +def _alias_present(text_upper: str, alias: str) -> bool: + normalised_alias = _normalise(alias) + if not normalised_alias: + return False + return re.search(rf"(? BankIdentityMatch | None: + raw_text = str(text or "") + text_upper = _normalise(raw_text) + ifsc_upper = re.sub(r"[^A-Z0-9]", "", str(ifsc or "").upper()) + file_upper = _normalise(Path(source_file or "").stem) + + best: BankIdentityMatch | None = None + for bank in BANK_IDENTITIES: + score = 0.0 + evidence: list[str] = [] + + # Longest/special IFSC prefixes carry the strongest identity signal. + matching_prefixes = [prefix for prefix in bank.ifsc_prefixes if ifsc_upper.startswith(prefix.upper())] + if matching_prefixes: + prefix = max(matching_prefixes, key=len) + score += 0.72 if len(prefix) > 4 else 0.58 + evidence.append(f"IFSC prefix {prefix}") + + for domain in bank.domains: + if domain.lower() in raw_text.lower(): + score += 0.72 + evidence.append(f"domain {domain}") + break + + alias_hits = [alias for alias in bank.aliases if _alias_present(text_upper, alias)] + if alias_hits: + longest = max(alias_hits, key=len) + # Long formal headings are stronger than abbreviations such as SBI. + score += 0.72 if len(_normalise(longest)) >= 12 else 0.36 + evidence.append(f"name {longest}") + + if any(_alias_present(file_upper, alias) for alias in bank.aliases): + score += 0.10 + evidence.append("filename") + + confidence = min(score, 0.99) + if confidence < 0.50: + continue + candidate = BankIdentityMatch(bank.name, bank.category, confidence, tuple(dict.fromkeys(evidence))) + if best is None or candidate.confidence > best.confidence: + best = candidate + + return best + + +def apply_detected_bank_identity(meta, df, text: str): + match = detect_bank_identity( + text, + ifsc=str(getattr(meta, "ifsc", "") or ""), + source_file=str(getattr(meta, "source_file", "") or ""), + ) + if match is None: + return meta, df + + # Do not let a reused layout/parser brand the statement as another bank. + # A high-confidence independent identity always wins. + if match.confidence >= 0.68: + meta.bank_name = match.name + if df is not None and not df.empty: + df = df.copy() + df["bank_name"] = match.name + return meta, df diff --git a/app/modules/bank_statement_analyzer/parsers/registry.py b/app/modules/bank_statement_analyzer/parsers/registry.py index 0ad35fe..568c4d8 100644 --- a/app/modules/bank_statement_analyzer/parsers/registry.py +++ b/app/modules/bank_statement_analyzer/parsers/registry.py @@ -1,9 +1,5 @@ from __future__ import annotations -import logging - -logger = logging.getLogger(__name__) - from .idfc import IDFCFirstParser from .axis import AxisParser from .hdfc import HDFCParser @@ -18,12 +14,15 @@ from .yes_bank import YesBankParser from .city_union_bank import CityUnionBankParser from .bank_of_baroda import BankOfBarodaParser from .rbl_bank import RBLBankParser +from .andhra_pradesh_grameena_bank import AndhraPradeshGrameenaBankParser +from .bank_identity import apply_detected_bank_identity from .base import extract_text -from .template_engine import parse_with_template + PARSERS = [ IDFCFirstParser, AxisParser, + AndhraPradeshGrameenaBankParser, CentralBankOfIndiaParser, YesBankParser, CityUnionBankParser, @@ -43,6 +42,7 @@ PARSERS = [ BANK_OPTIONS = [ ("auto", "Auto Detect"), ("axis", "Axis Bank"), + ("andhra_pradesh_grameena_bank", "Andhra Pradesh Grameena Bank"), ("central_bank_of_india", "Central Bank of India"), ("yes_bank", "YES Bank"), ("city_union_bank", "City Union Bank"), @@ -60,6 +60,7 @@ BANK_OPTIONS = [ BANK_PARSERS = { "axis": [AxisParser], + "andhra_pradesh_grameena_bank": [AndhraPradeshGrameenaBankParser], "central_bank_of_india": [CentralBankOfIndiaParser], "yes_bank": [YesBankParser], "city_union_bank": [CityUnionBankParser], @@ -77,7 +78,11 @@ BANK_PARSERS = { def detect_parser(text): - scored = sorted(((parser.detect(text), parser) for parser in PARSERS), key=lambda item: item[0], reverse=True) + scored = sorted( + ((parser.detect(text), parser) for parser in PARSERS), + key=lambda item: item[0], + reverse=True, + ) if not scored or scored[0][0] <= 0: return None, 0 return scored[0][1](), scored[0][0] @@ -87,115 +92,50 @@ def _selected_parser(bank_key: str, text: str): candidates = BANK_PARSERS.get(bank_key, []) if not candidates: return None, 0 - scored = sorted(((parser.detect(text), parser) for parser in candidates), key=lambda item: item[0], reverse=True) + scored = sorted( + ((parser.detect(text), parser) for parser in candidates), + key=lambda item: item[0], + reverse=True, + ) if scored and scored[0][0] > 0: return scored[0][1](), scored[0][0] return None, 0 -def _usable_result(result) -> bool: - if result is None: - return False - try: - _meta, frame = result - return frame is not None and not frame.empty - except Exception: - return False - - -def _attempt(parser, path, text: str, route: str): - try: - result = parser.parse(path, text) - if _usable_result(result): - meta, frame = result - logger.info( - "Bank analyzer route accepted: route=%s parser=%s rows=%s file=%s", - route, getattr(meta, "parser_name", parser.__class__.__name__), len(frame), path, - ) - return result - logger.warning( - "Bank analyzer route returned no rows: route=%s parser=%s file=%s", - route, parser.__class__.__name__, path, - ) - except Exception as exc: - logger.warning( - "Bank analyzer route failed: route=%s parser=%s file=%s error=%s", - route, parser.__class__.__name__, path, exc, exc_info=True, - ) - return None - - -def _template_first(path, text: str, hint: str): - try: - result = parse_with_template(path, text=text, bank_hint=hint) - if _usable_result(result): - meta, frame = result - logger.info( - "Bank analyzer template accepted: parser=%s rows=%s file=%s", - getattr(meta, "parser_name", "TemplateBasedStatementParser"), len(frame), path, - ) - return result - logger.warning("Bank analyzer template returned no rows: file=%s", path) - except Exception as exc: - logger.warning( - "Bank analyzer template rejected: file=%s error=%s", path, exc, exc_info=True, - ) - return None - - -def _ordered_bank_candidates(text: str): - scored = sorted( - ((parser.detect(text), parser) for parser in PARSERS), - key=lambda item: item[0], - reverse=True, - ) - return [parser for score, parser in scored if score > 0] +def _parse_and_identify(parser, path, text: str): + meta, df = parser.parse(path, text) + return apply_detected_bank_identity(meta, df, text) def parse_pdf(path, bank_hint: str | None = None): hint = (bank_hint or "auto").strip().lower() text = extract_text(path) - template_result = _template_first(path, text, hint) - if template_result is not None: - return template_result + if hint == "hsbc": + meta, df = HSBCParser().parse(path, text) + return apply_detected_bank_identity(meta, df, text) if hint and hint != "auto": - candidates = BANK_PARSERS.get(hint, []) - for parser_class in sorted(candidates, key=lambda cls: cls.detect(text), reverse=True): - result = _attempt(parser_class(), path, text, f"selected:{hint}") - if result is not None: - return result - label = dict(BANK_OPTIONS).get(hint, "the selected bank") + parser, score = _selected_parser(hint, text) + if parser is None: + label = dict(BANK_OPTIONS).get(hint, "the selected bank") + raise ValueError( + f"The uploaded statement does not match the selected {label} format. " + "Please verify the selected bank or choose Auto Detect." + ) + return _parse_and_identify(parser, path, text) + + parser, score = detect_parser(text) + if parser is None and not (text or "").strip(): + # Image-only statements cannot be identified by pdftotext. HSBCParser + # performs its own OCR identification and raises a precise mismatch error. + try: + meta, df = HSBCParser().parse(path, text) + return apply_detected_bank_identity(meta, df, text) + except ValueError: + pass + if parser is None: raise ValueError( - f"The uploaded statement could not be parsed by a validated layout template or the " - f"selected {label} parser. Please verify that the PDF is readable and supported." + "Unsupported statement format. Select the bank manually or add a bank-specific parser for this statement layout." ) - - attempted: set[type] = set() - for parser_class in _ordered_bank_candidates(text): - attempted.add(parser_class) - result = _attempt(parser_class(), path, text, "auto-detected") - if result is not None: - return result - - if not (text or "").strip(): - result = _attempt(HSBCParser(), path, text, "image-only-ocr") - if result is not None: - return result - - # Last-resort compatibility pass: a detector may miss a new layout even - # though the bank-specific parser can parse its visual table correctly. - for parser_class in PARSERS: - if parser_class in attempted: - continue - result = _attempt(parser_class(), path, text, "compatibility-fallback") - if result is not None: - return result - - raise ValueError( - "The bank statement format was identified, but no transaction rows could be extracted. " - "The template engine and all bank-specific parsers were attempted. " - "Please verify that the PDF is text-readable and that this statement layout is supported." - ) - + return _parse_and_identify(parser, path, text)