114 lines
7.1 KiB
Python
114 lines
7.1 KiB
Python
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):
|
|
# pdfplumber's layout mode can insert multiple spaces inside headings
|
|
# (for example, 'ACCOUNT STATEMENT'). Normalize whitespace before
|
|
# matching so the same bank PDF is detected whether pdftotext is
|
|
# installed in the runtime image or the pdfplumber fallback is used.
|
|
u=norm(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')
|
|
# Some Indian Bank statements leave these values blank. Do not let a
|
|
# value from the adjacent ACCOUNT SUMMARY column become the customer
|
|
# name merely because PDF text extraction merges the two columns.
|
|
customer=find(r'Account Holder Name[ \t]*([^\n]*)',text)
|
|
customer=norm(customer)
|
|
if customer and not re.search(r'^(Opening Balance|Account Type|Account Number|Customer)',customer,re.I):
|
|
meta.customer_name=customer
|
|
meta.account_number=find(r'Account Number[ \t]*([0-9X*]{4,})',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)
|