Files
arrr-erp/app/modules/bank_statement_analyzer/parsers/indian_bank.py
T
2026-07-13 22:12:47 +05:30

391 lines
13 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
import pandas as pd
from .base import *
from .common import find
def _signed_amount(value: str | None, suffix: str | None) -> float | None:
"""Return CR as positive and DR as negative."""
parsed = amount(value)
if parsed is None:
return None
return -parsed if (suffix or "").upper() == "DR" else parsed
def _parse_modern_date(value: str) -> str:
"""Normalize both '01 Apr 2025' and 'Apr 01 2025' to ISO date."""
return pd.to_datetime(value, dayfirst=True, errors="raise").strftime("%Y-%m-%d")
class IndianBankModernParser(BaseParser):
bank_name = "Indian Bank"
parser_name = "IndianBankModernParser"
@classmethod
def detect(cls, text):
# pdfplumber's layout mode can insert multiple spaces inside headings
# (for example, 'ACCOUNT STATEMENT'). Normalize whitespace before
# matching so the same bank PDF is detected whether pdftotext is
# installed in the runtime image or the pdfplumber fallback is used.
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",
)
# Some Indian Bank statements leave these values blank. Do not let a
# value from the adjacent ACCOUNT SUMMARY column become the customer
# name merely because PDF text extraction merges the two columns.
customer = find(r"Account Holder Name[ \t]*([^\n]*)", text)
customer = norm(customer)
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,
)
period_match = re.search(
r"For period:\s*(\d{2}\s+[A-Za-z]{3}\s+\d{4})\s*-\s*"
r"(\d{2}\s+[A-Za-z]{3}\s+\d{4})",
text,
re.I,
)
if period_match:
meta.period_from = _parse_modern_date(period_match.group(1))
meta.period_to = _parse_modern_date(period_match.group(2))
opening_match = re.search(
r"Opening Balance\s+INR\s*([\d,]+\.\d{2})\s*(CR|DR)?",
text,
re.I,
)
if opening_match:
meta.opening_balance = _signed_amount(
opening_match.group(1),
opening_match.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_match = re.search(
r"Ending Balance\s+INR\s*([\d,]+\.\d{2})\s*(CR|DR)?",
text,
re.I,
)
if closing_match:
meta.closing_balance = _signed_amount(
closing_match.group(1),
closing_match.group(2),
)
# Indian Bank's current PDF layout is seen in both date orders,
# depending on the text extraction engine:
# 01 Apr 2025 ...
# Apr 01 2025 ...
# Accept both without changing the parser selected for older samples.
date_re = re.compile(
r"^\s*((?:\d{2}\s+[A-Za-z]{3}|[A-Za-z]{3}\s+\d{2})\s+\d{4})\s+(.*)$"
)
rows = []
current = None
page = 1
for line in text.splitlines():
if "\f" in line:
page += line.count("\f")
match = date_re.match(line)
if match:
if current:
rows.append(current)
transaction_date = _parse_modern_date(match.group(1))
current = {
"transaction_date": transaction_date,
"value_date": transaction_date,
"lines": [match.group(2)],
"source_page": page,
}
elif (
current
and line.strip()
and not re.match(
r"^(Date\s+Transaction|ACCOUNT STATEMENT|Page)",
line.strip(),
re.I,
)
):
current["lines"].append(line)
if current:
rows.append(current)
out = []
previous_balance = meta.opening_balance
for row in rows:
first_line = row["lines"][0]
all_text = norm(" ".join(row["lines"]))
# Balance is always the last INR amount and may be CR or DR.
balance_match = re.search(
r"INR\s*([\d,]+\.\d{2})\s*(CR|DR)\s*$",
first_line,
re.I,
)
if balance_match:
balance = _signed_amount(
balance_match.group(1),
balance_match.group(2),
)
prefix = first_line[: balance_match.start()]
else:
balance = None
prefix = first_line
amount_matches = list(
re.finditer(r"INR\s*([\d,]+\.\d{2})", prefix, re.I)
)
transaction_amount = (
amount(amount_matches[-1].group(1)) if amount_matches else None
)
debit = None
credit = None
if (
transaction_amount is not None
and previous_balance is not None
and 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
# Fallback for the first row or when running-balance inference is
# unavailable. In the rendered Indian Bank table, a dash occupies
# the empty debit/credit column. Determine the side from text
# preceding the transaction amount.
if transaction_amount is not None and debit is None and credit is None:
before_amount = prefix[: amount_matches[-1].start()]
after_amount = prefix[amount_matches[-1].end() :]
# "- INR 1,000.00" means debit is empty, therefore credit.
if re.search(r"-\s*$", before_amount):
credit = transaction_amount
# "INR 1,000.00 -" means credit is empty, therefore debit.
elif re.match(r"^\s*-", after_amount):
debit = transaction_amount
else:
# Position fallback retained for extraction engines that
# preserve table spacing but omit the dash placeholder.
if amount_matches[-1].start() < 52:
debit = transaction_amount
else:
credit = transaction_amount
narration = re.split(
r"\s+INR\s*[\d,]+\.\d{2}",
all_text,
1,
flags=re.I,
)[0]
reference_no = ""
reference_match = re.search(
r"(?:NEFT|IMPS|UPI|RTGS)[/A-Z0-9-]{6,}",
all_text,
re.I,
)
if reference_match:
reference_no = reference_match.group(0)
if debit is None and credit is None:
continue
out.append(
{
**row,
"narration": narration,
"reference_no": reference_no,
"debit": debit,
"credit": credit,
"balance": balance,
}
)
if balance is not None:
previous_balance = balance
return meta, finalize(pd.DataFrame(out), meta)
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)
m = re.search(
r"STATEMENT OF ACCOUNT from\s*(\d{2}/\d{2}/\d{4})\s*to\s*"
r"(\d{2}/\d{2}/\d{4})",
text,
re.I,
)
if m:
meta.period_from = pd.to_datetime(
m.group(1), dayfirst=True
).strftime("%Y-%m-%d")
meta.period_to = pd.to_datetime(
m.group(2), dayfirst=True
).strftime("%Y-%m-%d")
date_re = re.compile(
r"^\s*(\d{2}/\d{2})(?:/\d{4})?\s+"
r"(\d{2}/\d{2})(?:/\d{4})?\s+(.*)$"
)
rows = []
current = None
page = 1
for line in text.splitlines():
if "\f" in line:
page += line.count("\f")
match = date_re.match(line)
if match:
if current:
rows.append(current)
year = meta.period_from[:4] if meta.period_from else "2024"
transaction_date = match.group(1).replace(" ", "") + "/" + year
value_date = match.group(2).replace(" ", "") + "/" + 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)
out = []
previous_balance = None
for row in rows:
first_line = row["lines"][0]
all_text = norm(" ".join(row["lines"]))
balance_match = re.search(
r"([\d,]+\.\d{2})(CR|DR)\s*$",
first_line,
re.I,
)
if not balance_match:
continue
balance = _signed_amount(
balance_match.group(1),
balance_match.group(2),
)
prefix = first_line[: 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 = None
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:
credit = transaction_amount if numbers[-1].start() > 70 else None
debit = transaction_amount if numbers[-1].start() <= 70 else None
reference_no = ""
reference_match = re.search(
r"(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}",
all_text,
re.I,
)
if reference_match:
reference_no = reference_match.group(0)
out.append(
{
**row,
"narration": all_text,
"reference_no": reference_no,
"debit": debit,
"credit": credit,
"balance": balance,
}
)
previous_balance = balance
if out and meta.opening_balance is None:
first = out[0]
transaction_amount = (first.get("debit") or 0) - (
first.get("credit") or 0
)
meta.opening_balance = round(
(first["balance"] or 0) + transaction_amount,
2,
)
return meta, finalize(pd.DataFrame(out), meta)