from __future__ import annotations import re from pathlib import Path import pandas as pd from .base import BaseParser, StatementMeta, amount, extract_text, finalize, norm from .common import DATE_TOKEN_PATTERN, date_iso, find, parse_flexible_date def _signed_amount(value: str | None, suffix: str | None) -> float | None: parsed = amount(value) if parsed is None: return None return -parsed if (suffix or "").upper() == "DR" else parsed def _nearest_column(position: int, debit_pos: int, credit_pos: int, balance_pos: int) -> str: distances = { "debit": abs(position - debit_pos), "credit": abs(position - credit_pos), "balance": abs(position - balance_pos), } return min(distances, key=distances.get) class IndianBankModernParser(BaseParser): bank_name = "Indian Bank" parser_name = "IndianBankModernParser" @classmethod def detect(cls, text): u = norm(text).upper() return 0.98 if "ACCOUNT STATEMENT" in u and "TRANSACTION DETAILS" in u and "TOTAL CREDITS" in u else 0 def parse(self, path, text=None): text = text or extract_text(path) meta = StatementMeta( bank_name=self.bank_name, source_file=Path(path).name, parser_name=self.parser_name, confidence="High", ) customer = norm(find(r"Account Holder Name[ \t]*([^\n]*)", text)) if customer and not re.search(r"^(Opening Balance|Account Type|Account Number|Customer)", customer, re.I): meta.customer_name = customer meta.account_number = find(r"Account Number[ \t]*([0-9X*]{4,})", text) meta.ifsc = find(r"IFSC[ \t]*([A-Z0-9]{8,})", text) period = re.search(rf"For period:\s*({DATE_TOKEN_PATTERN})\s*(?:-|to)\s*({DATE_TOKEN_PATTERN})", text, re.I) if period: meta.period_from = date_iso(period.group(1)) meta.period_to = date_iso(period.group(2)) opening = re.search(r"Opening Balance\s+INR\s*([\d,]+\.\d{2})\s*(CR|DR)?", text, re.I) if opening: meta.opening_balance = _signed_amount(opening.group(1), opening.group(2)) meta.total_credit = amount(find(r"Total Credits\s+\+\s*INR\s*([\d,]+\.\d{2})", text)) meta.total_debit = amount(find(r"Total Debits\s+-\s*INR\s*([\d,]+\.\d{2})", text)) closing = re.search(r"Ending Balance\s+INR\s*([\d,]+\.\d{2})\s*(CR|DR)?", text, re.I) if closing: meta.closing_balance = _signed_amount(closing.group(1), closing.group(2)) date_re = re.compile(rf"^\s*({DATE_TOKEN_PATTERN})\s+(.*)$", re.I) amount_re = re.compile(r"INR\s*([\d,]+\.\d{2})\s*(CR|DR)?", re.I) rows: list[dict] = [] current: dict | None = None page = 1 # Defaults match the wide first-page layout. They are replaced from # every page header, which also supports narrower subsequent pages. debit_pos, credit_pos, balance_pos = 56, 80, 94 for raw_line in text.splitlines(): if "\f" in raw_line: page += raw_line.count("\f") line = raw_line.rstrip("\n") upper = line.upper() if "TRANSACTION DETAILS" in upper and "DEBITS" in upper and "CREDITS" in upper and "BALANCE" in upper: debit_pos = line.upper().find("DEBITS") credit_pos = line.upper().find("CREDITS") balance_pos = line.upper().rfind("BALANCE") continue match = date_re.match(line) if match and not pd.isna(parse_flexible_date(match.group(1))): if current: rows.append(current) current = { "transaction_date": date_iso(match.group(1)), "value_date": date_iso(match.group(1)), "first_line": line, "detail_start": match.start(2), "continuation": [], "source_page": page, "debit_pos": debit_pos, "credit_pos": credit_pos, "balance_pos": balance_pos, } continue if not current or not line.strip(): continue if re.match(r"^(\s*Date\s+Transaction|\s*ACCOUNT STATEMENT|\s*Indian Bank\s*\||\s*Ending Balance|\s*Total\s+INR)", line, re.I): continue current["continuation"].append(line.strip()) if current: rows.append(current) output: list[dict] = [] previous_balance = meta.opening_balance for row in rows: first_line = row["first_line"] tokens = list(amount_re.finditer(first_line)) if not tokens: continue debit = credit = balance = None balance_suffix = None token_columns: list[tuple[str, re.Match]] = [] for token in tokens: column = _nearest_column(token.start(), row["debit_pos"], row["credit_pos"], row["balance_pos"]) token_columns.append((column, token)) # The running balance is the rightmost amount and is also checked # against the header-derived balance column. balance_token = tokens[-1] balance_suffix = balance_token.group(2) balance = _signed_amount(balance_token.group(1), balance_suffix) for column, token in token_columns[:-1]: value = amount(token.group(1)) if column == "debit": debit = value elif column == "credit": credit = value # When text extraction collapses spacing, infer the transaction side # from the running balance rather than guessing by token position. if debit is None and credit is None and len(tokens) >= 2: txn = amount(tokens[-2].group(1)) if txn is not None and previous_balance is not None and balance is not None: if abs((previous_balance - txn) - balance) < 0.05: debit = txn elif abs((previous_balance + txn) - balance) < 0.05: credit = txn # If the balance has no explicit DR/CR suffix, preserve continuity. if balance is not None and previous_balance is not None: expected = previous_balance + (credit or 0) - (debit or 0) if abs(expected - balance) >= 0.05 and abs(expected + balance) < 0.05: balance = -balance if debit is None and credit is None: continue first_detail_end = min((m.start() for m in tokens), default=len(first_line)) first_detail = first_line[row["detail_start"]:first_detail_end].strip() narration_parts = [first_detail] + row["continuation"] narration = norm(" ".join(part for part in narration_parts if part)) reference_no = "" reference = re.search(r"(?:NEFT|IMPS|UPI|RTGS)[/A-Z0-9-]{6,}", narration, re.I) if reference: reference_no = reference.group(0) output.append( { "transaction_date": row["transaction_date"], "value_date": row["value_date"], "narration": narration, "reference_no": reference_no, "debit": debit, "credit": credit, "balance": balance, "source_page": row["source_page"], } ) if balance is not None: previous_balance = balance frame = finalize(pd.DataFrame(output), meta) if not frame.empty: # Validate and repair metadata from the parsed rows only when the # statement summary did not supply it. first = frame.iloc[0] calculated_opening = (first["balance"] or 0) + (first["debit"] or 0) - (first["credit"] or 0) if meta.opening_balance is None: meta.opening_balance = round(calculated_opening, 2) if meta.closing_balance is None: meta.closing_balance = float(frame["balance"].dropna().iloc[-1]) return meta, frame class IndianBankLegacyParser(BaseParser): bank_name = "Indian Bank" parser_name = "IndianBankLegacyParser" @classmethod def detect(cls, text): u = text.upper() return 0.97 if "STATEMENT OF ACCOUNT FROM" in u and "REMITTER" in u and "CHEQUE NO" in u else 0 def parse(self, path, text=None): text = text or extract_text(path) meta = StatementMeta( bank_name=self.bank_name, source_file=Path(path).name, parser_name=self.parser_name, confidence="Medium", ) meta.account_number = find(r"for Account Number\s*\.?\s*([0-9X*]+)", text) period = re.search(rf"STATEMENT OF ACCOUNT from\s*({DATE_TOKEN_PATTERN})\s*to\s*({DATE_TOKEN_PATTERN})", text, re.I) if period: meta.period_from = date_iso(period.group(1)) meta.period_to = date_iso(period.group(2)) # Legacy files may omit the year on each transaction. Preserve the # previous behaviour while accepting every supported full-date format. default_year = int(meta.period_from[:4]) if meta.period_from else None full_date_re = re.compile(rf"^\s*({DATE_TOKEN_PATTERN})\s+({DATE_TOKEN_PATTERN})\s+(.*)$", re.I) short_date_re = re.compile(r"^\s*(\d{1,2}[-/.]\d{1,2})\s+(\d{1,2}[-/.]\d{1,2})\s+(.*)$") rows = [] current = None page = 1 for line in text.splitlines(): if "\f" in line: page += line.count("\f") match = full_date_re.match(line) or short_date_re.match(line) if match: if current: rows.append(current) transaction_date = date_iso(match.group(1), default_year=default_year) value_date = date_iso(match.group(2), default_year=default_year) current = { "transaction_date": transaction_date, "value_date": value_date, "lines": [match.group(3)], "source_page": page, } elif current and line.strip() and not re.match(r"^(Value Post|Date Date|STATEMENT OF ACCOUNT|Page No)", line.strip(), re.I): current["lines"].append(line) if current: rows.append(current) output = [] previous_balance = None for row in rows: first = row["lines"][0] all_text = norm(" ".join(row["lines"])) balance_match = re.search(r"([\d,]+\.\d{2})(CR|DR)\s*$", first, re.I) if not balance_match: continue balance = _signed_amount(balance_match.group(1), balance_match.group(2)) prefix = first[: balance_match.start()] numbers = list(re.finditer(r"(? 70: credit = transaction_amount else: debit = transaction_amount if debit is None and credit is None: continue reference = re.search(r"(?:NEFT|IMPS|UPI|RTGS)[/A-Z0-9-]{6,}", all_text, re.I) output.append( { **row, "narration": norm(prefix[: numbers[-1].start()] + " " + " ".join(row["lines"][1:])), "reference_no": reference.group(0) if reference else "", "debit": debit, "credit": credit, "balance": balance, } ) previous_balance = balance return meta, finalize(pd.DataFrame(output), meta)