Fix Indian Bank extraction and add shared multi-format dates for all banks

This commit is contained in:
A R R R Associates
2026-07-16 21:56:08 +05:30
parent 43ab9c9aae
commit d9ae1519a4
10 changed files with 340 additions and 311 deletions
@@ -1,20 +1,157 @@
from __future__ import annotations
import re
from .base import amount, norm
from datetime import datetime
from typing import Iterable
def find(pattern,text,group=1,flags=re.I|re.M):
m=re.search(pattern,text,flags)
return norm(m.group(group)) if m else ''
import pandas as pd
def date_iso(s):
import pandas as pd
x=pd.to_datetime(s,errors='coerce',dayfirst=True)
return '' if pd.isna(x) else x.strftime('%Y-%m-%d')
from .base import norm
# Shared date support for all bank parsers. Indian bank statements and the
# other supported banks can be rendered differently by pdftotext/pdfplumber,
# so parsers must not depend on one literal date layout.
SUPPORTED_DATE_FORMATS: tuple[str, ...] = (
"%d %b %Y",
"%d %B %Y",
"%b %d %Y",
"%B %d %Y",
"%d %b, %Y",
"%d %B, %Y",
"%b %d, %Y",
"%B %d, %Y",
"%d-%b-%Y",
"%d-%B-%Y",
"%d/%b/%Y",
"%d/%B/%Y",
"%d-%m-%Y",
"%d/%m/%Y",
"%d.%m.%Y",
"%Y-%m-%d",
"%Y/%m/%d",
"%d %b %y",
"%d %B %y",
"%b %d %y",
"%B %d %y",
"%d-%b-%y",
"%d-%B-%y",
"%d/%b/%y",
"%d/%B/%y",
"%d-%m-%y",
"%d/%m/%y",
"%d.%m.%y",
)
# A permissive token used only to locate a candidate date at the start of a
# transaction line. parse_flexible_date() performs the actual validation.
DATE_TOKEN_PATTERN = (
r"(?:"
r"\d{1,2}\s+[A-Za-z]{3,9},?\s+\d{2,4}"
r"|[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{2,4}"
r"|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}"
r"|\d{4}[-/.]\d{1,2}[-/.]\d{1,2}"
r"|\d{1,2}[-/.][A-Za-z]{3,9}[-/.]\d{2,4}"
r")"
)
DATE_TOKEN_RE = re.compile(DATE_TOKEN_PATTERN, re.I)
LEADING_DATE_RE = re.compile(rf"^\s*({DATE_TOKEN_PATTERN})(?:\s+|$)(.*)$", re.I)
def find(pattern, text, group=1, flags=re.I | re.M):
m = re.search(pattern, text, flags)
return norm(m.group(group)) if m else ""
def _normalise_date_text(value: str) -> str:
value = norm(value)
value = value.replace("", "-").replace("", "-")
value = re.sub(r"\s*,\s*", ", ", value)
value = re.sub(r"\s+", " ", value)
return value.strip(" ,")
def parse_flexible_date(value, *, default_year: int | None = None) -> pd.Timestamp | pd.NaT:
"""Parse an Indian bank date without silently swapping day and month.
Explicit formats are attempted before pandas' parser. Numeric dates are
always interpreted day-first because all supported statements are Indian
banking statements. A default year may be supplied for legacy DD/MM rows.
"""
if value is None or (isinstance(value, float) and pd.isna(value)):
return pd.NaT
if isinstance(value, (pd.Timestamp, datetime)):
return pd.Timestamp(value)
text = _normalise_date_text(str(value))
if not text:
return pd.NaT
if default_year and re.fullmatch(r"\d{1,2}[-/.]\d{1,2}", text):
text = f"{text}/{default_year}"
for fmt in SUPPORTED_DATE_FORMATS:
try:
return pd.Timestamp(datetime.strptime(text, fmt))
except ValueError:
continue
# Final guarded fallback for extraction noise. dayfirst=True is explicit
# and yearfirst is enabled only when the candidate begins with four digits.
parsed = pd.to_datetime(
text,
errors="coerce",
dayfirst=not bool(re.match(r"^\d{4}[-/.]", text)),
yearfirst=bool(re.match(r"^\d{4}[-/.]", text)),
)
return pd.NaT if pd.isna(parsed) else pd.Timestamp(parsed)
def date_iso(value, *, default_year: int | None = None) -> str:
parsed = parse_flexible_date(value, default_year=default_year)
return "" if pd.isna(parsed) else parsed.strftime("%Y-%m-%d")
def leading_dates(line: str, *, maximum: int = 2) -> tuple[list[str], str]:
"""Return up to ``maximum`` validated dates from the start of a line."""
remaining = line
dates: list[str] = []
for _ in range(maximum):
match = LEADING_DATE_RE.match(remaining)
if not match:
break
candidate = match.group(1)
if pd.isna(parse_flexible_date(candidate)):
break
dates.append(candidate)
remaining = match.group(2)
return dates, remaining
def find_date_tokens(text: str) -> list[str]:
return [m.group(0) for m in DATE_TOKEN_RE.finditer(text or "") if not pd.isna(parse_flexible_date(m.group(0)))]
def split_pages(text):
return text.split("\f")
def split_pages(text): return text.split('\f')
def infer_mode(n):
u=(n or '').upper()
for k,v in [('UPI','UPI'),('NEFT','NEFT'),('IMPS','IMPS'),('RTGS','RTGS'),('CASH DEPOSIT','Cash Deposit'),('CASH WITHDRAWAL','Cash Withdrawal'),('ATM','ATM'),('CHEQUE','Cheque'),('CHQ','Cheque'),('POS','POS'),('EDC','Card Settlement'),('ACH','ACH')]:
if k in u:return v
return 'Other'
u = (n or "").upper()
for k, v in (
("UPI", "UPI"),
("NEFT", "NEFT"),
("IMPS", "IMPS"),
("RTGS", "RTGS"),
("CASH DEPOSIT", "Cash Deposit"),
("CASH WITHDRAWAL", "Cash Withdrawal"),
("ATM", "ATM"),
("CHEQUE", "Cheque"),
("CHQ", "Cheque"),
("POS", "POS"),
("EDC", "Card Settlement"),
("ACH", "ACH"),
):
if k in u:
return v
return "Other"