Add bank statement analyzer with automatic work storage

This commit is contained in:
A R R R Associates
2026-07-13 10:12:19 +05:30
parent d391e9b443
commit bbc5afe1c0
23 changed files with 1047 additions and 0 deletions
@@ -0,0 +1,2 @@
from .registry import parse_pdf, detect_parser, PARSERS
from .base import StatementMeta, STANDARD_COLUMNS
@@ -0,0 +1,37 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class AxisParser(BaseParser):
bank_name='Axis Bank'; parser_name='AxisParser'
@classmethod
def detect(cls,text): return 0.98 if 'SMART STATEMENT REPORT' in text.upper() and 'UTIB' in text.upper() 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')
# name is first meaningful line after report title
m=re.search(r'Smart Statement Report\s*\n\s*([^\n]+)',text,re.I); meta.customer_name=norm(m.group(1)) if m else ''
meta.account_number=find(r'Statement of Account No\s*-\s*([^\s]*)',text)
meta.ifsc=find(r'IFSC:\s*([A-Z0-9]+)',text)
m=re.search(r'for period\s*\((\d{2}/\d{2}/\d{4})\s+to\s+(\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')
meta.opening_balance=amount(find(r'Opening Balance:\s*INR\s*([\d,]+\.\d{2})',text))
pat=re.compile(r'^\s*(\d+)\s+(\d{2}/\d{2}/\d{4})\s+(\d{2}/\d{2}/\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page+=line.count('\f')
m=pat.match(line)
if m:
if cur: rows.append(cur)
cur={'transaction_date':m.group(2),'value_date':m.group(3),'body':m.group(4),'source_page':page}
elif cur and line.strip() and not re.match(r'^(S\. No\.|Smart Statement|Page )',line.strip(),re.I): cur['body']+=' '+line.strip()
if cur: rows.append(cur)
out=[]
for r in rows:
b=norm(r['body']); ma=re.search(r'INR\s*([\d,]+\.\d{2})\s+(CR|DR)\s+INR\s*([\d,]+\.\d{2})',b,re.I)
if not ma: continue
txn=amount(ma.group(1)); typ=ma.group(2).upper(); bal=amount(ma.group(3)); narr=b[:ma.start()].strip(); ref=''
z=re.search(r'([A-Z0-9/-]{8,})',narr); ref=z.group(1) if z else ''
out.append({**r,'narration':narr,'reference_no':ref,'debit':txn if typ=='DR' else None,'credit':txn if typ=='CR' else None,'balance':bal})
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
import re, subprocess, tempfile
import pandas as pd
import pdfplumber
@dataclass
class StatementMeta:
bank_name: str = ""
customer_name: str = ""
account_number: str = ""
customer_id: str = ""
ifsc: str = ""
period_from: str = ""
period_to: str = ""
opening_balance: Optional[float] = None
total_debit: Optional[float] = None
total_credit: Optional[float] = None
closing_balance: Optional[float] = None
source_file: str = ""
parser_name: str = ""
confidence: str = "Medium"
def to_dict(self):
return asdict(self)
STANDARD_COLUMNS = [
"transaction_date", "value_date", "narration", "reference_no",
"debit", "credit", "balance", "bank_name", "customer_name",
"account_number", "source_file", "source_page", "parser_name"
]
def amount(v):
if v is None: return None
s=str(v).strip().replace('INR','').replace('Rs.','').replace('','').replace(',','').replace('+','')
s=s.replace('CR','').replace('DR','').strip()
if s in ('','-'): return None
neg=s.startswith('-')
s=s.lstrip('-')
try:
x=float(s)
return -x if neg else x
except: return None
def norm(s): return re.sub(r'\s+',' ',str(s or '')).strip()
def extract_text(path: str|Path) -> str:
"""Prefer pdftotext layout output; fall back to pdfplumber."""
path=str(path)
try:
p=subprocess.run(['pdftotext','-layout',path,'-'], capture_output=True, text=True, timeout=120)
if p.returncode==0 and len(p.stdout.strip())>50:
return p.stdout
except Exception:
pass
parts=[]
with pdfplumber.open(path) as pdf:
for page in pdf.pages:
parts.append(page.extract_text(x_tolerance=1,y_tolerance=3,layout=True) or '')
return '\n\f\n'.join(parts)
def page_of_line(text: str, position: int) -> int:
return text[:position].count('\f')+1
def finalize(df: pd.DataFrame, meta: StatementMeta) -> pd.DataFrame:
if df is None or df.empty:
return pd.DataFrame(columns=STANDARD_COLUMNS)
for c in ['debit','credit','balance']:
df[c]=pd.to_numeric(df.get(c),errors='coerce')
for c in ['transaction_date','value_date']:
df[c]=pd.to_datetime(df.get(c),errors='coerce',dayfirst=True)
df['narration']=df.get('narration','').fillna('').map(norm)
df['reference_no']=df.get('reference_no','').fillna('').map(norm)
df['bank_name']=meta.bank_name
df['customer_name']=meta.customer_name
df['account_number']=meta.account_number
df['source_file']=meta.source_file
df['parser_name']=meta.parser_name
if 'source_page' not in df: df['source_page']=None
for c in STANDARD_COLUMNS:
if c not in df: df[c]=None
return df[STANDARD_COLUMNS]
class BaseParser:
bank_name='Unknown'
parser_name='BaseParser'
@classmethod
def detect(cls,text:str)->float: return 0.0
def parse(self,path:str|Path,text:str|None=None): raise NotImplementedError
@@ -0,0 +1,20 @@
from __future__ import annotations
import re
from .base import amount, norm
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 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')
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'
@@ -0,0 +1,58 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class HDFCParser(BaseParser):
bank_name='HDFC Bank'; parser_name='HDFCParser'
@classmethod
def detect(cls,text):
u=text.upper(); return 0.97 if 'HDFC' in u and 'WITHDRAWAL AMT.' in u and 'DEPOSIT AMT.' 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')
meta.account_number=find(r'Account No\s*:?\s*([0-9X*]+)',text)
meta.customer_id=find(r'Cust ID\s*:?\s*([0-9X*]+)',text)
meta.ifsc=find(r'(?:RTGS/\s*NEFT IFSC|IFSC)\s*:?\s*([A-Z0-9]+)',text)
m=re.search(r'From\s*:\s*(\d{2}/\d{2}/\d{4})\s+To\s*:\s*(\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')
# first non-empty line before address usually contains customer name; allow manual override in UI
m=re.search(r'\n\s{2,}([^\n:]{3,60})\s*\n.*?Address\s*:',text,re.S|re.I)
if m: meta.customer_name=norm(m.group(1).splitlines()[-1])
lines=text.splitlines(); rows=[]; cur=None; page=1
wd_pos,dep_pos,bal_pos=130,165,190
date_re=re.compile(r'^\s*(\d{2}/\d{2}/\d{2})\s+(.*)$')
for line in lines:
if '\f' in line: page += line.count('\f')
if 'Withdrawal Amt.' in line and 'Deposit Amt.' in line:
wd_pos=line.find('Withdrawal Amt.'); dep_pos=line.find('Deposit Amt.'); bal_pos=line.find('Closing Balance'); continue
m=date_re.match(line)
if m:
if cur: rows.append(cur)
cur={'transaction_date':m.group(1),'value_date':'','raw_lines':[line],'source_page':page,'wd_pos':wd_pos,'dep_pos':dep_pos,'bal_pos':bal_pos}
elif cur and line.strip() and not re.match(r'^(Page No|Statement of account|Date\s+Narration|This is a computer)',line.strip(),re.I): cur['raw_lines'].append(line)
if cur: rows.append(cur)
out=[]
for r in rows:
first=r['raw_lines'][0]; body=' '.join(x.strip() for x in r['raw_lines'])
# identify value date and ref on first line
dts=list(re.finditer(r'\d{2}/\d{2}/\d{2}',first))
if len(dts)>1: r['value_date']=dts[-1].group()
else: r['value_date']=r['transaction_date']
nums=list(re.finditer(r'(?<!\d)(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}(?!\d)',first))
debit=credit=bal=None
for n in nums:
x=amount(n.group()); p=n.start()
if p>=r['bal_pos']-5: bal=x
elif p>=r['dep_pos']-5: credit=x
elif p>=r['wd_pos']-5: debit=x
if bal is None and nums: bal=amount(nums[-1].group())
# narration is text between date and likely ref/value-date region, plus continuation lines
narr=first[dts[0].end():]
if len(dts)>1: narr=narr[:dts[-1].start()-dts[0].end()]
narr=norm(narr+' '+' '.join(x.strip() for x in r['raw_lines'][1:]))
ref=''; z=re.search(r'\b([A-Z0-9]{10,})\b',body); ref=z.group(1) if z else ''
if debit is None and credit is None: continue
out.append({**r,'narration':narr,'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,56 @@
from __future__ import annotations
import re
import pandas as pd
from pathlib import Path
from .base import BaseParser, StatementMeta, extract_text, amount, norm, finalize, page_of_line
from .common import find, date_iso
class IDFCFirstParser(BaseParser):
bank_name='IDFC FIRST Bank'; parser_name='IDFCFirstParser'
@classmethod
def detect(cls,text):
u=text.upper(); return 0.99 if 'IDFC FIRST BANK' in u and 'STATEMENT PERIOD' 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')
meta.customer_name=find(r'CUSTOMER NAME\s*:\s*([^\n]+)',text)
meta.account_number=find(r'ACCOUNT NO\s*:\s*([0-9X*]+)',text)
meta.customer_id=find(r'CUSTOMER ID\s*:\s*([0-9X*]+)',text)
meta.ifsc=find(r'IFSC\s*:\s*([A-Z0-9]+)',text)
m=re.search(r'STATEMENT PERIOD\s*:\s*(\d{4}-\d{2}-\d{2})\s+TO\s+(\d{4}-\d{2}-\d{2})',text,re.I)
if m: meta.period_from,meta.period_to=m.groups()
m=re.search(r'Opening Balance\s+Total Debit\s+Total Credit\s+Closing Balance\s*\n\s*([\d,.]+)\s+([\d,.]+)\s+([\d,.]+)\s+([\d,.]+)',text,re.I)
if m: meta.opening_balance,meta.total_debit,meta.total_credit,meta.closing_balance=map(amount,m.groups())
lines=text.splitlines(); rows=[]; current=None; pos=0
pat=re.compile(r'^\s*(\d{2}-[A-Za-z]{3}-\d{4})\s+(\d{2}-[A-Za-z]{3}-\d{4})\s+(.*)$')
for line in lines:
mm=pat.match(line)
if mm:
if current: rows.append(current)
current={'transaction_date':mm.group(1),'value_date':mm.group(2),'body':mm.group(3),'source_page':page_of_line(text,pos)}
elif current and line.strip() and not re.match(r'^(STATEMENT|Opening Balance|REGISTERED OFFICE|Page \d+)',line.strip(),re.I):
current['body']+=' '+line.strip()
pos+=len(line)+1
if current: rows.append(current)
out=[]; prev=meta.opening_balance
for r in rows:
body=norm(r['body']); nums=list(re.finditer(r'(?<!\d)(?:\d{1,3}(?:,\d{2,3})+|\d+)\.\d{2}(?!\d)',body))
if not nums: continue
bal=amount(nums[-1].group()); rem=body[:nums[-1].start()].strip(); vals=[amount(x.group()) for x in nums[:-1]]
debit=credit=None
if vals:
txn=vals[-1]
if prev is not None and bal is not None:
d1=round(prev-txn,2); c1=round(prev+txn,2)
if abs(d1-bal)<0.02: debit=txn
elif abs(c1-bal)<0.02: credit=txn
else:
# explicit columns often leave one amount only; infer from change
credit=txn if bal>=prev else None; debit=txn if bal<prev else None
else: credit=txn
ref=''
z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/-](?:MOB/|OPM/|INET/|DR/|CR/)?([A-Z0-9]{8,})',rem,re.I)
if z: ref=z.group(1)
out.append({**r,'narration':rem,'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if bal is not None: prev=bal
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,102 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class IndianBankModernParser(BaseParser):
bank_name='Indian Bank'; parser_name='IndianBankModernParser'
@classmethod
def detect(cls,text):
u=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')
meta.customer_name=find(r'Account Holder Name\s*\n\s*([^\n]+)',text)
meta.account_number=find(r'Account Number\s*\n?\s*([0-9X*]+)',text)
m=re.search(r'For period:\s*(\d{2}\s+[A-Za-z]{3}\s+\d{4})\s*-\s*(\d{2}\s+[A-Za-z]{3}\s+\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')
meta.opening_balance=amount(find(r'Opening Balance\s+INR\s*([\d,]+\.\d{2})',text))
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))
meta.closing_balance=amount(find(r'Ending Balance\s+INR\s*([\d,]+\.\d{2})',text))
date_re=re.compile(r'^\s*([A-Za-z]{3}\s+\d{2}\s+\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page+=line.count('\f')
m=date_re.match(line)
if m:
if cur: rows.append(cur)
cur={'transaction_date':m.group(1),'value_date':m.group(1),'lines':[m.group(2)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(Date\s+Transaction|ACCOUNT STATEMENT|Page)',line.strip(),re.I): cur['lines'].append(line)
if cur: rows.append(cur)
out=[]; prev=meta.opening_balance
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
vals=[(m.start(),amount(m.group(1))) for m in re.finditer(r'INR\s*([\d,]+\.\d{2})',first,re.I)]
# columns in sample: debit ~45, credit ~65, balance ~85. Use signs/placeholders and running balance.
bal=vals[-1][1] if vals else None; debit=credit=None
if len(vals)>=2:
txn=vals[-2][1]
before=first[:re.search(r'INR\s*[\d,]+\.\d{2}',first,re.I).start()] if re.search(r'INR\s*[\d,]+\.\d{2}',first,re.I) else first
# presence of '-' before first amount often means no debit; inspect spacing/position
p=vals[-2][0]
if prev is not None and bal is not None:
if abs((prev-txn)-bal)<0.05: debit=txn
elif abs((prev+txn)-bal)<0.05: credit=txn
if debit is None and credit is None:
if p<52: debit=txn
else: credit=txn
narr=re.split(r'\s+INR\s*[\d,]+\.\d{2}',alltxt,1,flags=re.I)[0]
ref=''; z=re.search(r'(?:NEFT|IMPS|UPI|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else ''
if debit is None and credit is None: continue
out.append({**r,'narration':narr,'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if bal is not None: prev=bal
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*(\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+(\d{2}/\d{2})(?:/\d{4})?\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page+=line.count('\f')
m=date_re.match(line)
if m:
if cur: rows.append(cur)
year=(meta.period_from[:4] if meta.period_from else '2024')
td=m.group(1).replace(' ','')+'/'+year; vd=m.group(2).replace(' ','')+'/'+year
cur={'transaction_date':td,'value_date':vd,'lines':[m.group(3)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(Value Post|Date Date|STATEMENT OF ACCOUNT|Page No)',line.strip(),re.I): cur['lines'].append(line)
if cur: rows.append(cur)
out=[]; prev=None
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
# balance is amount followed by CR/DR at far right
mb=re.search(r'([\d,]+\.\d{2})(CR|DR)\s*$',first,re.I)
if not mb: continue
bal=amount(mb.group(1)); prefix=first[:mb.start()]
nums=list(re.finditer(r'(?<!\d)([\d,]+\.\d{2})(?!\d)',prefix))
txn=amount(nums[-1].group(1)) if nums else None
debit=credit=None
if txn is not None and prev is not None:
if abs((prev-txn)-bal)<0.05: debit=txn
elif abs((prev+txn)-bal)<0.05: credit=txn
if txn is not None and debit is None and credit is None:
# column location: DR before CR in legacy format
credit=txn if nums[-1].start()>70 else None; debit=txn if nums[-1].start()<=70 else None
narr=alltxt
ref=''; z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else ''
out.append({**r,'narration':narr,'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
prev=bal
if out and meta.opening_balance is None:
first=out[0]; txn=(first.get('debit') or 0)-(first.get('credit') or 0); meta.opening_balance=round((first['balance'] or 0)+txn,2)
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,53 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class IndusIndParser(BaseParser):
bank_name='IndusInd Bank'; parser_name='IndusIndParser'
@classmethod
def detect(cls,text): return 0.98 if 'INDUSIND' in text.upper() and 'STATEMENT OF ACCOUNT' in text.upper() 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'Account Number\s*:\s*([0-9X*]+)',text)
meta.customer_id=find(r'Cust\.Reln\.No\s*:\s*([0-9X*]+)',text)
meta.ifsc=find(r'IFSC Code\s*:\s*([A-Z0-9]+)',text)
m=re.search(r'Period\s*:\s*(\d{2}[/-][A-Za-z0-9]{2,3}[/-]\d{2,4})\s*(?:to|TO|-)\s*(\d{2}[/-][A-Za-z0-9]{2,3}[/-]\d{2,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')
meta.total_debit=amount(find(r'Total Withdrawal Amount\s*:\s*([\d,]+\.\d{2})',text))
meta.total_credit=amount(find(r'Total Deposit Amount\s*:\s*([\d,]+\.\d{2})',text))
# rows start with date and often end with amount Dr/Cr and balance Cr
date_re=re.compile(r'^\s*(\d{2}[-/]?[A-Za-z]{3}[-/]?\d{2,4}|\d{2}-\d{2}-\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page+=line.count('\f')
m=date_re.match(line)
if m:
if cur: rows.append(cur)
cur={'transaction_date':m.group(1),'value_date':m.group(1),'lines':[m.group(2)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(Statement Summary|Opening Balance|Total Withdrawal|Branch Address|The limits)',line.strip(),re.I): cur['lines'].append(line)
if cur: rows.append(cur)
out=[]; prev=None
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
# Typical: narration ref debit Dr credit Cr balance Cr
tokens=list(re.finditer(r'([\d,]+\.\d{2})\s*(Dr|Cr)',first,re.I))
if not tokens: continue
debit=credit=bal=None
if len(tokens)>=2:
bal=amount(tokens[-1].group(1))
txn=amount(tokens[-2].group(1)); typ=tokens[-2].group(2).upper()
if typ=='DR': debit=txn
else: credit=txn
elif len(tokens)==1:
# carried forward / balance-only line; skip
continue
narr=first[:tokens[-2].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:])
ref=''; z=re.search(r'\b([A-Z0-9]{8,})\b',narr); ref=z.group(1) if z else ''
out.append({**r,'narration':norm(narr),'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if out:
f=out[0]; meta.opening_balance=round((f['balance'] or 0)+(f.get('debit') or 0)-(f.get('credit') or 0),2)
meta.closing_balance=out[-1]['balance']
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,54 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class KotakParser(BaseParser):
bank_name='Kotak Mahindra Bank'; parser_name='KotakParser'
@classmethod
def detect(cls,text):
u=text.upper(); return 0.98 if 'KOTAK' in u and 'CHEQUE/REFERENCE#' in u and 'TRANSACTION DETAILS' 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')
meta.account_number=find(r'Account\s*#\s*Variant\s*KOTAK\s*\n.*?([0-9X*]{6,})',text,flags=re.I|re.S)
meta.ifsc=find(r'IFSC\s+([A-Z0-9]+)',text)
m=re.search(r'(\d{2}\s+[A-Za-z]{3},\s*\d{4})\s*-\s*(\d{2}\s+[A-Za-z]{3},\s*\d{4})',text)
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}\s+[A-Za-z]{3},\s*\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page+=line.count('\f')
m=date_re.match(line)
if m:
if ' - ' in m.group(2) and re.match(r'\d{2}\s+[A-Za-z]{3},',m.group(2).strip()): continue
if cur: rows.append(cur)
cur={'transaction_date':m.group(1),'value_date':m.group(1),'lines':[m.group(2)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(DATE\s+TRANSACTION|Need help|Page \d+)',line.strip(),re.I): cur['lines'].append(line)
if cur: rows.append(cur)
out=[]
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
# amounts are signed in debit/credit columns, final unsigned balance
nums=list(re.finditer(r'([+-]?\d{1,3}(?:,\d{3})*\.\d{2})',first))
if not nums: continue
bal=amount(nums[-1].group(1)); debit=credit=None
if len(nums)>=2:
raw=nums[-2].group(1); txn=abs(amount(raw) or 0)
if raw.strip().startswith('-'): debit=txn
elif raw.strip().startswith('+'): credit=txn
else:
# position fallback: debit column before credit
credit=txn if nums[-2].start()>95 else None; debit=txn if nums[-2].start()<=95 else None
elif 'OPENING BALANCE' in first.upper():
meta.opening_balance=bal; continue
narr=first[:nums[-2].start() if len(nums)>=2 else nums[-1].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:])
ref=''; z=re.search(r'\b(?:UPI|NEFT|IMPS|RTGS)-[A-Z0-9]+\b',alltxt,re.I); ref=z.group(0) if z else ''
if debit is None and credit is None: continue
out.append({**r,'narration':norm(narr),'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if out:
if meta.opening_balance is None:
f=out[0]; meta.opening_balance=round((f['balance'] or 0)+(f.get('debit') or 0)-(f.get('credit') or 0),2)
meta.closing_balance=out[-1]['balance']
return meta,finalize(pd.DataFrame(out),meta)
@@ -0,0 +1,25 @@
from .idfc import IDFCFirstParser
from .axis import AxisParser
from .hdfc import HDFCParser
from .indian_bank import IndianBankModernParser, IndianBankLegacyParser
from .indusind import IndusIndParser
from .kotak import KotakParser
from .sbi import SBIModernParser, SBIOtherParser
from .base import extract_text
PARSERS=[IDFCFirstParser,AxisParser,HDFCParser,IndianBankModernParser,IndianBankLegacyParser,IndusIndParser,KotakParser,SBIOtherParser,SBIModernParser]
def detect_parser(text):
scored=sorted(((p.detect(text),p) for p in PARSERS),key=lambda x:x[0],reverse=True)
if not scored or scored[0][0] <= 0: return None,0
return scored[0][1](),scored[0][0]
def parse_pdf(path, bank_hint=None):
text=extract_text(path)
if bank_hint:
for p in PARSERS:
if bank_hint.lower() in p.bank_name.lower() or bank_hint.lower() in p.__name__.lower():
return p().parse(path,text)
parser,score=detect_parser(text)
if parser is None: raise ValueError('Unsupported statement format. Add a bank-specific parser or use a supported sample format.')
return parser.parse(path,text)
@@ -0,0 +1,100 @@
from __future__ import annotations
import re, pandas as pd
from pathlib import Path
from .base import *
from .common import find
class SBIModernParser(BaseParser):
bank_name='State Bank of India'; parser_name='SBIModernParser'
@classmethod
def detect(cls,text):
u=text.upper(); return 0.98 if 'STATE BANK OF INDIA' in u and 'REF NO./CHEQUE' in u and 'DETAILS' 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')
meta.customer_name=find(r'Account Name\s*:?\s*([^\n]+)',text)
meta.account_number=find(r'Account Number\s*:?\s*([0-9X*]+)',text)
meta.ifsc=find(r'IFS Code\s*:?\s*([A-Z0-9]+)',text)
m=re.search(r'Account Statement from\s*(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})\s+to\s+(\d{1,2}\s+[A-Za-z]{3}\s+\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')
meta.opening_balance=amount(find(r'Balance as on[^\n]*\n\s*([\d,]+\.\d{2})',text))
# Format A: date details ref debit credit balance
date_re=re.compile(r'^\s*(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page += line.count('\f')
m=date_re.match(line)
if m:
if cur: rows.append(cur)
cur={'transaction_date':m.group(1),'value_date':m.group(1),'lines':[m.group(2)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(Date\s+Details|Txn Date|Account Statement|State Bank)',line.strip(),re.I):
cur['lines'].append(line)
if cur: rows.append(cur)
out=[]; prev=meta.opening_balance
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
nums=list(re.finditer(r'(?<!\d)([\d,]+\.\d{2})(?!\d)',first))
if not nums: continue
bal=amount(nums[-1].group(1)); debit=credit=None
# use dashes and positions from SBI layout: debit then credit then balance
if len(nums)>=2:
txn=amount(nums[-2].group(1)); p=nums[-2].start()
if prev is not None and bal is not None:
if abs((prev-txn)-bal)<0.05: debit=txn
elif abs((prev+txn)-bal)<0.05: credit=txn
if debit is None and credit is None:
if p >= 78: credit=txn
else: debit=txn
narr=first[:nums[-2].start() if len(nums)>=2 else nums[-1].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:])
ref=''; z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else ''
if debit is None and credit is None: continue
out.append({**r,'narration':norm(narr),'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if bal is not None: prev=bal
if out:
if meta.opening_balance is None:
f=out[0]; meta.opening_balance=round((f['balance'] or 0)+(f.get('debit') or 0)-(f.get('credit') or 0),2)
meta.closing_balance=out[-1]['balance']
return meta,finalize(pd.DataFrame(out),meta)
class SBIOtherParser(BaseParser):
bank_name='State Bank of India'; parser_name='SBIOtherParser'
@classmethod
def detect(cls,text):
u=text.upper(); return 0.97 if 'TXN DATE' in u and 'VALUE' in u and 'REF NO./CHEQUE' in u and 'BY TRANSFER' 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')
meta.customer_name=find(r'Account Name\s*:\s*([^\n]+)',text)
meta.account_number=find(r'Account Number\s*:?\s*([0-9X*]+)',text)
meta.ifsc=find(r'IFS Code\s*:?\s*([A-Z0-9]+)',text)
m=re.search(r'Account Statement from\s*(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})\s+to\s+(\d{1,2}\s+[A-Za-z]{3}\s+\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')
m=re.search(r'Balance as on\s+[^\n]+\n',text,re.I)
date_re=re.compile(r'^\s*(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})\s+(\d{1,2}\s+[A-Za-z]{3}\s+\d{4})\s+(.*)$')
rows=[]; cur=None; page=1
for line in text.splitlines():
if '\f' in line: page += line.count('\f')
mm=date_re.match(line)
if mm:
if cur: rows.append(cur)
cur={'transaction_date':mm.group(1),'value_date':mm.group(2),'lines':[mm.group(3)],'source_page':page}
elif cur and line.strip() and not re.match(r'^(Txn Date|Account Statement|Account Name)',line.strip(),re.I): cur['lines'].append(line)
if cur: rows.append(cur)
out=[]
for r in rows:
first=r['lines'][0]; alltxt=norm(' '.join(r['lines']))
nums=list(re.finditer(r'(?<!\d)([\d,]+\.\d{2})(?!\d)',first))
if not nums: continue
bal=amount(nums[-1].group(1)); debit=credit=None
if len(nums)>=2:
txn=amount(nums[-2].group(1)); p=nums[-2].start()
# Based on header layout: Debit starts before Credit
if p < 55: debit=txn
else: credit=txn
narr=first[:nums[-2].start() if len(nums)>=2 else nums[-1].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:])
ref=''; z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else ''
if debit is None and credit is None: continue
out.append({**r,'narration':norm(narr),'reference_no':ref,'debit':debit,'credit':credit,'balance':bal})
if out:
f=out[0]; meta.opening_balance=round((f['balance'] or 0)+(f.get('debit') or 0)-(f.get('credit') or 0),2); meta.closing_balance=out[-1]['balance']
return meta,finalize(pd.DataFrame(out),meta)