diff --git a/app/modules/bank_statement_analyzer/parsers/registry.py b/app/modules/bank_statement_analyzer/parsers/registry.py index c0151d0..9ee59a0 100644 --- a/app/modules/bank_statement_analyzer/parsers/registry.py +++ b/app/modules/bank_statement_analyzer/parsers/registry.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from .idfc import IDFCFirstParser from .axis import AxisParser @@ -8,7 +8,7 @@ from .hsbc import HSBCParser from .indian_bank import IndianBankModernParser, IndianBankLegacyParser from .indusind import IndusIndParser from .kotak import KotakParser -from .sbi import SBIModernParser, SBIOtherParser +from .sbi import SBIAccountSummaryParser, SBIModernParser, SBIOtherParser from .central_bank_of_india import CentralBankOfIndiaParser from .yes_bank import YesBankParser from .city_union_bank import CityUnionBankParser @@ -31,6 +31,7 @@ PARSERS = [ IndianBankLegacyParser, IndusIndParser, KotakParser, + SBIAccountSummaryParser, SBIOtherParser, SBIModernParser, ] @@ -67,7 +68,7 @@ BANK_PARSERS = { "indian_bank": [IndianBankModernParser, IndianBankLegacyParser], "indusind": [IndusIndParser], "kotak": [KotakParser], - "sbi": [SBIOtherParser, SBIModernParser], + "sbi": [SBIAccountSummaryParser, SBIOtherParser, SBIModernParser], } diff --git a/app/modules/bank_statement_analyzer/parsers/sbi.py b/app/modules/bank_statement_analyzer/parsers/sbi.py index fa806c2..ca3ad2b 100644 --- a/app/modules/bank_statement_analyzer/parsers/sbi.py +++ b/app/modules/bank_statement_analyzer/parsers/sbi.py @@ -26,7 +26,39 @@ _HEADER_RE = re.compile( +def _summary_metrics(text: str) -> dict[str, float | int] | None: + """Extract SBI's printed statement summary without depending on line wrapping.""" + match = re.search( + r"Brought\s+Forward.*?Dr\s+Count\s+Cr\s+Count.*?" + r"Total\s+Debits.*?Total\s+Credits.*?Closing\s+Balance.*?" + r"([\d,]+\.\d{2})\s*(CR|DR)?\s+" + r"(\d{1,6})\s+(\d{1,6})\s+" + r"([\d,]+\.\d{2})\s+([\d,]+\.\d{2})\s+" + r"([\d,]+\.\d{2})\s*(CR|DR)?", + text, + re.I | re.S, + ) + if not match: + return None + + def signed(value: str, suffix: str | None) -> float: + parsed = float(value.replace(",", "")) + return -parsed if (suffix or "").upper() == "DR" else parsed + + return { + "opening_balance": signed(match.group(1), match.group(2)), + "debit_count": int(match.group(3)), + "credit_count": int(match.group(4)), + "total_debit": float(match.group(5).replace(",", "")), + "total_credit": float(match.group(6).replace(",", "")), + "closing_balance": signed(match.group(7), match.group(8)), + } + + def _expected_summary_transactions(text: str) -> int | None: + summary = _summary_metrics(text) + if summary: + return int(summary["debit_count"]) + int(summary["credit_count"]) counts = re.findall( r"(?:Brought\s+Forward.*?)(\d{1,3})\s+(\d{1,3})\s+[\d,]+\.\d{2}\s+[\d,]+\.\d{2}", text, @@ -37,6 +69,16 @@ def _expected_summary_transactions(text: str) -> int | None: return sum(int(debit_count) + int(credit_count) for debit_count, credit_count in counts) +def _apply_printed_summary(meta: StatementMeta, text: str) -> None: + summary = _summary_metrics(text) + if not summary: + return + meta.opening_balance = float(summary["opening_balance"]) + meta.total_debit = float(summary["total_debit"]) + meta.total_credit = float(summary["total_credit"]) + meta.closing_balance = float(summary["closing_balance"]) + + def _ocr_yono_page(pdf_path: Path, page_number: int, work_dir: Path, dpi: int) -> tuple[int, str]: prefix = work_dir / f"sbi_yono_{page_number:05d}" image_path = prefix.with_suffix(".png") @@ -211,7 +253,7 @@ def _parse_rows( # physical line. Split before every detected date pair so each transaction # reaches the normal row collector independently. text = re.sub( - r"(? float: + upper = norm(text).upper() + required = ( + "STATE BANK OF INDIA", + "ACCOUNT SUMMARY", + "STATEMENT OF ACCOUNT", + "STATEMENT SUMMARY", + "BROUGHT FORWARD", + "DR COUNT", + "CR COUNT", + ) + if all(marker in upper for marker in required): + return 0.998 + return 0.0 + + def parse(self, path, text=None): + extracted_text = text or extract_text(path) + meta = _extract_common_meta(extracted_text, path, self.parser_name) + _apply_printed_summary(meta, extracted_text) + if meta.opening_balance is None: + meta.opening_balance = _opening_from_summary(extracted_text) + start_re = re.compile( + rf"^\s*({DATE_TOKEN_PATTERN})\s+({DATE_TOKEN_PATTERN})\s+(.*)$", + re.I, + ) + frame = _parse_rows(extracted_text, meta, start_re, 2) + + summary = _summary_metrics(extracted_text) + if summary and len(frame) != int(summary["debit_count"]) + int(summary["credit_count"]): + raise ValueError( + "SBI Account Summary rows could not be fully reconciled with the printed " + "debit and credit counts. Please retain the PDF and contact support." + ) + return meta, frame + + class SBIStandardParser(BaseParser): bank_name = "State Bank of India" parser_name = "SBIStandardParser"