from __future__ import annotations import re from datetime import datetime from typing import Iterable import pandas as pd 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 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"