Add SBI Account Summary layout template parser

This commit is contained in:
A R R R Associates
2026-08-04 10:29:49 +05:30
parent 7db38c7b04
commit 1dbde5e0b9
2 changed files with 107 additions and 7 deletions
@@ -1,4 +1,4 @@
from __future__ import annotations from __future__ import annotations
from .idfc import IDFCFirstParser from .idfc import IDFCFirstParser
from .axis import AxisParser from .axis import AxisParser
@@ -8,7 +8,7 @@ from .hsbc import HSBCParser
from .indian_bank import IndianBankModernParser, IndianBankLegacyParser from .indian_bank import IndianBankModernParser, IndianBankLegacyParser
from .indusind import IndusIndParser from .indusind import IndusIndParser
from .kotak import KotakParser from .kotak import KotakParser
from .sbi import SBIModernParser, SBIOtherParser from .sbi import SBIAccountSummaryParser, SBIModernParser, SBIOtherParser
from .central_bank_of_india import CentralBankOfIndiaParser from .central_bank_of_india import CentralBankOfIndiaParser
from .yes_bank import YesBankParser from .yes_bank import YesBankParser
from .city_union_bank import CityUnionBankParser from .city_union_bank import CityUnionBankParser
@@ -31,6 +31,7 @@ PARSERS = [
IndianBankLegacyParser, IndianBankLegacyParser,
IndusIndParser, IndusIndParser,
KotakParser, KotakParser,
SBIAccountSummaryParser,
SBIOtherParser, SBIOtherParser,
SBIModernParser, SBIModernParser,
] ]
@@ -67,7 +68,7 @@ BANK_PARSERS = {
"indian_bank": [IndianBankModernParser, IndianBankLegacyParser], "indian_bank": [IndianBankModernParser, IndianBankLegacyParser],
"indusind": [IndusIndParser], "indusind": [IndusIndParser],
"kotak": [KotakParser], "kotak": [KotakParser],
"sbi": [SBIOtherParser, SBIModernParser], "sbi": [SBIAccountSummaryParser, SBIOtherParser, SBIModernParser],
} }
@@ -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: 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( counts = re.findall(
r"(?:Brought\s+Forward.*?)(\d{1,3})\s+(\d{1,3})\s+[\d,]+\.\d{2}\s+[\d,]+\.\d{2}", r"(?:Brought\s+Forward.*?)(\d{1,3})\s+(\d{1,3})\s+[\d,]+\.\d{2}\s+[\d,]+\.\d{2}",
text, 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) 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]: 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}" prefix = work_dir / f"sbi_yono_{page_number:05d}"
image_path = prefix.with_suffix(".png") image_path = prefix.with_suffix(".png")
@@ -211,7 +253,7 @@ def _parse_rows(
# physical line. Split before every detected date pair so each transaction # physical line. Split before every detected date pair so each transaction
# reaches the normal row collector independently. # reaches the normal row collector independently.
text = re.sub( text = re.sub(
r"(?<!\n)(?=(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+" r"(?<=[^\n\d])(?=(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+"
r"(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+)", r"(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4})\s+)",
"\n", "\n",
text, text,
@@ -226,9 +268,10 @@ def _parse_rows(
rows: list[dict] = [] rows: list[dict] = []
current: dict | None = None current: dict | None = None
pending_prefix: list[str] = []
page = 1 page = 1
for raw_line in text.splitlines(): for raw_line in text.split("\n"):
page += _page_increment(raw_line) page += _page_increment(raw_line)
line = raw_line.replace("\f", "").rstrip() line = raw_line.replace("\f", "").rstrip()
match = start_re.match(line) match = start_re.match(line)
@@ -242,11 +285,24 @@ def _parse_rows(
"transaction_date": transaction_date, "transaction_date": transaction_date,
"value_date": value_date, "value_date": value_date,
"lines": [remainder], "lines": [remainder],
"prefix_lines": list(pending_prefix),
"source_page": page, "source_page": page,
} }
pending_prefix = []
continue continue
stripped = line.strip() stripped = line.strip()
is_mode_prefix = bool(
stripped
and re.fullmatch(
r"(?:DEP|WDL)\s+TFR(?:\s+INB.*)?|INTEREST\s+CREDIT|CEMTEX\s+DEP",
stripped,
re.I,
)
)
if is_mode_prefix:
pending_prefix = [stripped]
continue
if current and stripped and not _HEADER_RE.match(stripped): if current and stripped and not _HEADER_RE.match(stripped):
current["lines"].append(stripped) current["lines"].append(stripped)
@@ -257,7 +313,7 @@ def _parse_rows(
previous_balance = meta.opening_balance previous_balance = meta.opening_balance
for row in rows: for row in rows:
first_line = row["lines"][0] first_line = row["lines"][0]
full_text = norm(" ".join(row["lines"])) full_text = norm(" ".join([*row.get("prefix_lines", []), *row["lines"]]))
debit, credit, balance, first_narration = _row_values( debit, credit, balance, first_narration = _row_values(
first_line, first_line,
full_text, full_text,
@@ -266,7 +322,7 @@ def _parse_rows(
if balance is None: if balance is None:
continue continue
narration = norm(" ".join([first_narration, *row["lines"][1:]])) narration = norm(" ".join([*row.get("prefix_lines", []), first_narration, *row["lines"][1:]]))
if debit is None and credit is None and previous_balance is not None: if debit is None and credit is None and previous_balance is not None:
delta = round(balance - previous_balance, 2) delta = round(balance - previous_balance, 2)
if delta < -0.01: if delta < -0.01:
@@ -354,6 +410,49 @@ class SBIYONORelationshipParser(BaseParser):
return meta, frame return meta, frame
class SBIAccountSummaryParser(BaseParser):
"""SBI Account Summary layout with dual dates and a printed summary page."""
bank_name = "State Bank of India"
parser_name = "SBIAccountSummaryParser"
@classmethod
def detect(cls, text: str) -> 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): class SBIStandardParser(BaseParser):
bank_name = "State Bank of India" bank_name = "State Bank of India"
parser_name = "SBIStandardParser" parser_name = "SBIStandardParser"