Files
arrr-erp/app/modules/bank_statement_analyzer/parsers/indian_bank.py
T

289 lines
12 KiB
Python

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"(?<!\d)([\d,]+\.\d{2})(?!\d)", prefix))
transaction_amount = amount(numbers[-1].group(1)) if numbers else None
debit = credit = None
if transaction_amount is not None and previous_balance is not None:
if abs((previous_balance - transaction_amount) - balance) < 0.05:
debit = transaction_amount
elif abs((previous_balance + transaction_amount) - balance) < 0.05:
credit = transaction_amount
if transaction_amount is not None and debit is None and credit is None:
if numbers[-1].start() > 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)