Consolidate bank analyzer bank selection and classification updates

This commit is contained in:
A R R R Associates
2026-07-13 13:52:41 +05:30
parent 31f7c8fd3e
commit afb098aa75
5 changed files with 518 additions and 113 deletions
+366 -81
View File
@@ -1,107 +1,392 @@
from __future__ import annotations
from pathlib import Path
import re
import pandas as pd
from .parsers import parse_pdf
from .parsers.common import infer_mode
HIGH_VALUE_THRESHOLD = 50000.0
LOW_BALANCE_THRESHOLD = 1000.0
def clean_key(s):
s=re.sub(r'\s+',' ',str(s or '').upper()).strip()
return re.sub(r'\b\d{8,}\b','<REF>',s)
def enrich(df):
if df.empty:return df
def clean_key(value):
value = re.sub(r"\s+", " ", str(value or "").upper()).strip()
return re.sub(r"\b\d{8,}\b", "<REF>", value)
def _text(value) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def _amount(row) -> float:
return float((row.get("debit") or 0) + (row.get("credit") or 0))
def _direction(row) -> str:
return "Debit" if pd.notna(row.get("debit")) and float(row.get("debit") or 0) > 0 else "Credit"
def _extract_counterparty(narration: str, direction: str) -> str:
raw = _text(narration)
upper = raw.upper()
if not raw:
return "Unidentified"
if "CASH DEP" in upper or "CASH DEPOSIT" in upper:
return "Cash Deposit"
if "CASH WITHDRAW" in upper or "ATM WDL" in upper or "ATM/CASH" in upper and direction == "Debit":
return "Cash Withdrawal"
if "BANK CHARGE" in upper or "CHARGES" in upper or "COMMISSION" in upper:
return "Bank Charges"
if "INTEREST" in upper:
return "Bank Interest"
if "/UPI/" in upper or upper.startswith("UPI/"):
parts = [p.strip() for p in re.split(r"/", raw) if p.strip()]
ignored = {"UPI", "DR", "CR", "NA", "PAYMENT", "PHONEPE", "PAYTM"}
for part in parts[1:]:
cleaned = re.sub(r"^X+\d+$", "", part, flags=re.I).strip()
if cleaned and cleaned.upper() not in ignored and not re.fullmatch(r"\d{6,}", cleaned):
return cleaned[:120]
return "UPI / Mobile"
for marker in ("NEFT/", "RTGS/", "IMPS/", "TRANSFER TO", "TRANSFER FROM"):
if marker in upper:
parts = [p.strip() for p in re.split(r"/", raw) if p.strip()]
for part in parts[1:]:
if re.fullmatch(r"[A-Z0-9]{10,}", part.upper()):
continue
if re.fullmatch(r"\d+(?:\.\d+)?", part):
continue
if part.upper() in {"NEFT", "RTGS", "IMPS", "P2A", "P2P", "BRANCH : ATM SERVICE BRANCH"}:
continue
return part[:120]
if "CTS-CHQ" in upper or "CLEARING" in upper:
return "Cheque Clearing"
return raw[:120]
def _classify_row(row) -> tuple[str, str]:
narration = _text(row.get("narration"))
upper = narration.upper()
direction = row.get("direction") or _direction(row)
mode = str(row.get("mode") or "Other")
if "CASH DEP" in upper or "CASH DEPOSIT" in upper:
return "Cash Deposit / Cash Sales", "Cash Deposit"
if "CASH WITHDRAW" in upper or "ATM WDL" in upper or ("ATM/CASH" in upper and direction == "Debit"):
return "Cash Withdrawal", "Cash Withdrawal"
if any(token in upper for token in ("BANK CHARGE", "SERVICE CHARGE", "COMMISSION CHARGE", "IMPS COMMISSION", "SMS CHARGE")):
return "Bank Charges", "Bank Charges"
if "INTEREST" in upper:
return ("Interest Paid" if direction == "Debit" else "Interest Received"), "Bank Interest"
if any(token in upper for token in ("GST PAYMENT", "GST PMT", "CPIN", "GSTIN")) and direction == "Debit":
return "GST Payment", _extract_counterparty(narration, direction)
if any(token in upper for token in ("INCOME TAX", "ITNS", "TDS", "OLTAS", "NSDL TAX")) and direction == "Debit":
return "Tax / TDS Payment", _extract_counterparty(narration, direction)
if any(token in upper for token in ("SALARY", "PAYROLL")) and direction == "Debit":
return "Salary Payment", _extract_counterparty(narration, direction)
if "RENT" in upper and direction == "Debit":
return "Rent Expense", _extract_counterparty(narration, direction)
if any(token in upper for token in ("ELECTRICITY", "TANGEDCO", "EB BILL")) and direction == "Debit":
return "Electricity Expense", _extract_counterparty(narration, direction)
if any(token in upper for token in ("PHONEPE", "PAYTM", "RAZORPAY", "ONE 97", "PAYMENT AGGREGATOR", "ESCROW")) and direction == "Credit":
return "Payment Aggregator Settlement", _extract_counterparty(narration, direction)
if "REFUND" in upper or "REVERSAL" in upper:
return "Refund / Reversal", _extract_counterparty(narration, direction)
if any(token in upper for token in ("SELF", "OWN ACCOUNT", "SELF TRANSFER")) or "TRANSFER FROM" in upper and "MOBILE TRANSFER" in upper:
return "Self Transfer / Contra", _extract_counterparty(narration, direction)
if mode == "UPI" or "/UPI/" in upper or upper.startswith("UPI/"):
return ("UPI Payment / Expense" if direction == "Debit" else "UPI Customer Receipt"), _extract_counterparty(narration, direction)
if mode == "NEFT" or "NEFT" in upper:
return ("NEFT Payment" if direction == "Debit" else "NEFT Receipt"), _extract_counterparty(narration, direction)
if mode == "RTGS" or "RTGS" in upper:
return ("RTGS Payment" if direction == "Debit" else "RTGS Receipt"), _extract_counterparty(narration, direction)
if mode == "IMPS" or "IMPS" in upper:
return ("IMPS Payment" if direction == "Debit" else "IMPS Receipt"), _extract_counterparty(narration, direction)
if "CTS-CHQ" in upper or "CLEARING" in upper or mode == "Cheque":
return ("Cheque Payment" if direction == "Debit" else "Cheque Deposit"), "Cheque Clearing"
if direction == "Debit":
return "Other Bank Payment / Review", _extract_counterparty(narration, direction)
return "Other Bank Receipt / Review", _extract_counterparty(narration, direction)
def _review_note(row) -> str:
notes: list[str] = []
amount = float(row.get("amount") or 0)
balance = row.get("balance")
category = str(row.get("category") or "")
counterparty = str(row.get("counterparty") or "")
if amount >= HIGH_VALUE_THRESHOLD:
notes.append(f"High value transaction >= {HIGH_VALUE_THRESHOLD:,.0f}")
if category == "Cash Deposit / Cash Sales":
notes.append("Cash deposit - verify cash book/source")
elif category == "Cash Withdrawal":
notes.append("Cash withdrawal - verify cash book/use")
if category.endswith("/ Review") or counterparty == "Unidentified":
notes.append("Narration requires manual classification")
if amount >= 10000 and amount % 10000 == 0:
notes.append("Round/high amount - verify nature")
if pd.notna(balance) and float(balance) < LOW_BALANCE_THRESHOLD:
notes.append("Low balance after transaction")
if bool(row.get("exact_duplicate")):
notes.append("Exact duplicate / overlapping statement row")
elif bool(row.get("possible_duplicate")):
notes.append("Possible duplicate - review before exclusion")
return "; ".join(dict.fromkeys(notes))
def enrich(df, classification_enabled: bool = True):
if df.empty:
return df
x = df.copy()
x['mode']=x['narration'].map(infer_mode)
x['amount']=x['debit'].fillna(0)+x['credit'].fillna(0)
x['direction']=x['debit'].notna().map({True:'Debit',False:'Credit'})
x['narration_key']=x['narration'].map(clean_key)
x['exact_key']=x.apply(lambda r:f"{r.transaction_date}|{r.value_date}|{r.debit}|{r.credit}|{r.balance}|{clean_key(r.narration)}",axis=1)
x['exact_duplicate']=x.duplicated('exact_key',keep=False)
# possible duplicate: same date, direction, amount and normalized narration across different source files
x['possible_key']=x.apply(lambda r:f"{r.transaction_date}|{r.direction}|{r.amount:.2f}|{r.narration_key}",axis=1)
x['possible_duplicate']=x.duplicated('possible_key',keep=False) & ~x['exact_duplicate']
x["mode"] = x["narration"].map(infer_mode)
x["amount"] = x["debit"].fillna(0) + x["credit"].fillna(0)
x["direction"] = x.apply(_direction, axis=1)
x["narration_key"] = x["narration"].map(clean_key)
x["exact_key"] = x.apply(
lambda r: f"{r.transaction_date}|{r.value_date}|{r.debit}|{r.credit}|{r.balance}|{clean_key(r.narration)}",
axis=1,
)
x["exact_duplicate"] = x.duplicated("exact_key", keep=False)
x["possible_key"] = x.apply(
lambda r: f"{r.transaction_date}|{r.direction}|{r.amount:.2f}|{r.narration_key}", axis=1
)
x["possible_duplicate"] = x.duplicated("possible_key", keep=False) & ~x["exact_duplicate"]
if classification_enabled:
classified = x.apply(_classify_row, axis=1, result_type="expand")
x["category"] = classified[0]
x["counterparty"] = classified[1]
x["review_note"] = x.apply(_review_note, axis=1)
else:
x["category"] = "Classification disabled"
x["counterparty"] = x["narration"].map(lambda value: _text(value)[:120] or "Unidentified")
x["review_note"] = x.apply(_review_note, axis=1)
return x
def analyze_files(paths, customer_override='', account_override=''):
metas=[]; dfs=[]
for p in paths:
meta,df=parse_pdf(p)
if customer_override: meta.customer_name=customer_override; df['customer_name']=customer_override
if account_override: meta.account_number=account_override; df['account_number']=account_override
metas.append(meta); dfs.append(df)
all_df=enrich(pd.concat(dfs,ignore_index=True) if dfs else pd.DataFrame())
# remove exact overlap duplicates, retaining first source occurrence
unique_df=all_df.drop_duplicates('exact_key',keep='first').copy() if not all_df.empty else all_df.copy()
def analyze_files(paths, customer_override="", account_override="", bank_hint="auto", classification_enabled=True):
metas = []
frames = []
for path in paths:
meta, df = parse_pdf(path, bank_hint=bank_hint)
if customer_override:
meta.customer_name = customer_override
df["customer_name"] = customer_override
if account_override:
meta.account_number = account_override
df["account_number"] = account_override
metas.append(meta)
frames.append(df)
all_df = enrich(pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(), classification_enabled)
unique_df = all_df.drop_duplicates("exact_key", keep="first").copy() if not all_df.empty else all_df.copy()
return metas, all_df, unique_df
def reconcile(metas, all_df):
rows = []
for m in metas:
d=all_df[all_df.source_file.eq(m.source_file)] if not all_df.empty else pd.DataFrame()
ed=float(d.debit.sum()) if not d.empty else 0
ec=float(d.credit.sum()) if not d.empty else 0
last=float(d.balance.dropna().iloc[-1]) if not d.empty and d.balance.notna().any() else None
rows.append({**m.to_dict(),'extracted_transactions':len(d),'extracted_debit':ed,'extracted_credit':ec,'extracted_closing_balance':last,
'debit_difference':None if m.total_debit is None else round(ed-m.total_debit,2),
'credit_difference':None if m.total_credit is None else round(ec-m.total_credit,2),
'closing_difference':None if m.closing_balance is None or last is None else round(last-m.closing_balance,2)})
for meta in metas:
data = all_df[all_df.source_file.eq(meta.source_file)] if not all_df.empty else pd.DataFrame()
extracted_debit = float(data.debit.sum()) if not data.empty else 0
extracted_credit = float(data.credit.sum()) if not data.empty else 0
last_balance = float(data.balance.dropna().iloc[-1]) if not data.empty and data.balance.notna().any() else None
debit_diff = None if meta.total_debit is None else round(extracted_debit - meta.total_debit, 2)
credit_diff = None if meta.total_credit is None else round(extracted_credit - meta.total_credit, 2)
closing_diff = None if meta.closing_balance is None or last_balance is None else round(last_balance - meta.closing_balance, 2)
rows.append(
{
**meta.to_dict(),
"extracted_transactions": len(data),
"extracted_debit": extracted_debit,
"debit_difference": debit_diff,
"extracted_credit": extracted_credit,
"credit_difference": credit_diff,
"extracted_closing_balance": last_balance,
"closing_difference": closing_diff,
"status": "Reconciled" if all(value in (None, 0, 0.0) for value in (debit_diff, credit_diff, closing_diff)) else "Review",
}
)
return pd.DataFrame(rows)
def monthly_summary(df):
if df.empty:return pd.DataFrame()
x=df.copy(); x['month']=x.transaction_date.dt.to_period('M').astype(str)
return x.groupby('month',dropna=False).agg(transaction_count=('amount','size'),total_debit=('debit','sum'),total_credit=('credit','sum'),net_movement=('credit','sum')).reset_index().assign(net_movement=lambda z:z.total_credit-z.total_debit)
if df.empty:
return pd.DataFrame()
x = df.copy()
x["month"] = x.transaction_date.dt.to_period("M").astype(str)
summary = x.groupby("month", dropna=False).agg(
transaction_count=("amount", "size"),
opening_balance=("balance", "first"),
total_debit=("debit", "sum"),
total_credit=("credit", "sum"),
closing_balance=("balance", "last"),
).reset_index()
summary["net_movement"] = summary.total_credit - summary.total_debit
return summary
def mode_summary(df):
if df.empty:return pd.DataFrame()
return df.groupby(['mode','direction']).agg(transaction_count=('amount','size'),amount=('amount','sum')).reset_index()
if df.empty:
return pd.DataFrame()
return df.groupby(["mode", "direction"], dropna=False).agg(transaction_count=("amount", "size"), amount=("amount", "sum")).reset_index()
def category_summary(df):
if df.empty:
return pd.DataFrame()
result = df.groupby("category", dropna=False).agg(
transaction_count=("amount", "size"), debit=("debit", "sum"), credit=("credit", "sum")
).reset_index()
result["net_credit_debit"] = result.credit - result.debit
return result.sort_values(["transaction_count", "category"], ascending=[False, True])
def party_summary(df):
if df.empty:
return pd.DataFrame()
result = df.groupby(["counterparty", "category"], dropna=False).agg(
transaction_count=("amount", "size"), debit=("debit", "sum"), credit=("credit", "sum")
).reset_index()
result["net_credit_debit"] = result.credit - result.debit
return result.sort_values(["transaction_count", "counterparty"], ascending=[False, True])
def duplicate_summary(all_df):
return pd.DataFrame([
{'check':'All extracted rows','count':len(all_df)},
{'check':'Exact duplicate rows','count':int(all_df.exact_duplicate.sum()) if not all_df.empty else 0},
{'check':'Possible duplicate rows','count':int(all_df.possible_duplicate.sum()) if not all_df.empty else 0},
{'check':'Unique rows after exact deduplication','count':int(all_df.exact_key.nunique()) if not all_df.empty else 0},
])
return pd.DataFrame(
[
{"check": "All extracted rows", "count": len(all_df)},
{"check": "Exact duplicate rows", "count": int(all_df.exact_duplicate.sum()) if not all_df.empty else 0},
{"check": "Possible duplicate rows", "count": int(all_df.possible_duplicate.sum()) if not all_df.empty else 0},
{"check": "Unique rows after exact deduplication", "count": int(all_df.exact_key.nunique()) if not all_df.empty else 0},
]
)
def export_excel(output,metas,all_df,unique_df):
def draft_financials(df, metas):
if df.empty:
return pd.DataFrame(columns=["particulars", "amount", "treatment_notes", "finalisation_requirement"])
categories = df.groupby("category", dropna=False).agg(debit=("debit", "sum"), credit=("credit", "sum"))
receipt_categories = ["Cash Deposit / Cash Sales", "UPI Customer Receipt", "Payment Aggregator Settlement", "NEFT Receipt", "RTGS Receipt", "IMPS Receipt", "Cheque Deposit"]
payment_categories = ["UPI Payment / Expense", "NEFT Payment", "RTGS Payment", "IMPS Payment", "Cheque Payment"]
business_receipts = float(sum(categories.loc[c, "credit"] for c in receipt_categories if c in categories.index))
other_receipts = float(sum(categories.loc[c, "credit"] for c in categories.index if c not in receipt_categories and categories.loc[c, "credit"] > 0))
classified_payments = float(sum(categories.loc[c, "debit"] for c in payment_categories if c in categories.index))
other_payments = float(sum(categories.loc[c, "debit"] for c in categories.index if c not in payment_categories and categories.loc[c, "debit"] > 0))
opening = next((meta.opening_balance for meta in metas if meta.opening_balance is not None), None)
closing = next((meta.closing_balance for meta in reversed(metas) if meta.closing_balance is not None), None)
return pd.DataFrame(
[
["Potential business receipts / collections", business_receipts, "Narration-based grouping of customer, cash and settlement credits", "Verify with GST/sales register and cash book"],
["Other / unclassified receipts", other_receipts, "May include capital, loans, contra and other receipts", "Classify with books and supporting records"],
["Classified bank payments", classified_payments, "Narration-based payment classification", "Verify expense, purchase and drawings treatment"],
["Other / unclassified bank payments", other_payments, "Requires review", "Classify before final accounts"],
["Opening bank balance", opening, "Per first uploaded statement", "Verify statement continuity"],
["Closing bank balance", closing, "Per last uploaded statement", "Agree with bank reconciliation"],
],
columns=["particulars", "amount", "treatment_notes", "finalisation_requirement"],
)
def _workbook_columns(df):
preferred = [
"transaction_date", "value_date", "narration", "counterparty", "category", "mode", "direction",
"debit", "credit", "balance", "reference_no", "bank_name", "customer_name", "account_number",
"source_file", "source_page", "parser_name", "exact_duplicate", "possible_duplicate", "review_note",
]
return [column for column in preferred if column in df.columns]
def export_excel(output, metas, all_df, unique_df, financial_year="", selected_bank="auto", classification_enabled=True):
recon = reconcile(metas, all_df)
cust=next((m.customer_name for m in metas if m.customer_name),'')
acct=next((m.account_number for m in metas if m.account_number),'')
banks=', '.join(sorted({m.bank_name for m in metas}))
dashboard=pd.DataFrame([
['Customer / Account Holder',cust],['Account Number',acct],['Bank(s)',banks],
['Statements Uploaded',len(metas)],['Rows Extracted',len(all_df)],['Unique Transactions',len(unique_df)],
['Exact Duplicate Rows',int(all_df.exact_duplicate.sum()) if not all_df.empty else 0],
['Possible Duplicate Rows',int(all_df.possible_duplicate.sum()) if not all_df.empty else 0],
['Total Debit (Unique)',float(unique_df.debit.sum()) if not unique_df.empty else 0],
['Total Credit (Unique)',float(unique_df.credit.sum()) if not unique_df.empty else 0],
],columns=['Metric','Value'])
with pd.ExcelWriter(output,engine='xlsxwriter',datetime_format='dd-mmm-yyyy') as w:
dashboard.to_excel(w, sheet_name='Dashboard', index=False)
recon.to_excel(w, sheet_name='Statement Reconciliation', index=False)
all_df.to_excel(w, sheet_name='All Extracted Rows', index=False)
unique_df.to_excel(w, sheet_name='Unique Transactions', index=False)
all_df[all_df.exact_duplicate].to_excel(w, sheet_name='Exact Duplicates', index=False)
all_df[all_df.possible_duplicate].to_excel(w, sheet_name='Possible Duplicates', index=False)
monthly_summary(unique_df).to_excel(w, sheet_name='Monthly Summary', index=False)
mode_summary(unique_df).to_excel(w, sheet_name='Mode Summary', index=False)
duplicate_summary(all_df).to_excel(w, sheet_name='Duplicate Summary', index=False)
notes=pd.DataFrame({'Notes':[
'Exact duplicates use transaction date, value date, debit, credit, balance and normalized narration.',
'Possible duplicates use same date, direction, amount and normalized narration; review before deletion.',
'Bank-specific parsers are selected automatically. Customer name/account number can be manually overridden in the app.',
'The workbook is a bank-statement analysis aid, not a substitute for ledger, GST, inventory, receivable/payable and cash-book records.'
]})
notes.to_excel(w, sheet_name='Notes', index=False)
wb=w.book
head=wb.add_format({'bold':True,'bg_color':'#1F4E78','font_color':'white','border':1})
money=wb.add_format({'num_format':'#,##0.00'})
for name,ws in w.sheets.items():
ws.freeze_panes(1,0); ws.autofilter(0,0,0,max(0,ws.dim_colmax))
ws.set_row(0,22,head)
ws.set_column(0,max(0,ws.dim_colmax),18)
w.sheets['Dashboard'].set_column('A:A',32); w.sheets['Dashboard'].set_column('B:B',28)
customer = next((meta.customer_name for meta in metas if meta.customer_name), "")
account = next((meta.account_number for meta in metas if meta.account_number), "")
banks = ", ".join(sorted({meta.bank_name for meta in metas}))
exact_count = int(all_df.exact_duplicate.sum()) if not all_df.empty else 0
possible_count = int(all_df.possible_duplicate.sum()) if not all_df.empty else 0
opening = next((meta.opening_balance for meta in metas if meta.opening_balance is not None), None)
closing = next((meta.closing_balance for meta in reversed(metas) if meta.closing_balance is not None), None)
dashboard = pd.DataFrame(
[
["Customer / Account Holder", customer, "Extracted from statement or user override"],
["Account Number", account, "Extracted from statement or user override"],
["Bank(s)", banks, "Detected/selected parser result"],
["Financial Year", financial_year or "Not specified", "Optional user input"],
["Statements Uploaded", len(metas), "PDF files processed"],
["Rows Extracted", len(all_df), "Before exact duplicate removal"],
["Exact Duplicate Rows", exact_count, "Includes overlapping statement rows"],
["Possible Duplicate Rows", possible_count, "Requires manual review"],
["Unique Transactions", len(unique_df), "Used for summaries"],
["Opening Balance", opening, "From first available statement summary"],
["Total Debit (Unique)", float(unique_df.debit.sum()) if not unique_df.empty else 0, "After exact duplicate removal"],
["Total Credit (Unique)", float(unique_df.credit.sum()) if not unique_df.empty else 0, "After exact duplicate removal"],
["Closing Balance", closing, "From last available statement summary"],
["Classification", "Enabled" if classification_enabled else "Disabled", "Generic narration-based classification"],
],
columns=["Metric", "Value", "Remarks"],
)
all_export = all_df[_workbook_columns(all_df)].copy() if not all_df.empty else all_df.copy()
unique_export = unique_df[_workbook_columns(unique_df)].copy() if not unique_df.empty else unique_df.copy()
review_items = unique_export[unique_export["review_note"].fillna("").ne("")].copy() if not unique_export.empty else unique_export.copy()
notes = pd.DataFrame(
{
"Assumption / Method": [
"Extraction method",
"Debit/Credit identification",
"Exact duplicate detection",
"Possible duplicate detection",
"Bank selection",
"Classification",
"Important limitation",
"Files analysed",
],
"Details": [
"Transactions are extracted using the selected bank parser or automatic parser detection.",
"Debit and credit fields are taken from the bank-specific parser and reconciled to statement summaries where available.",
"Exact duplicates use transaction date, value date, debit, credit, balance and normalized narration; the first occurrence is retained.",
"Possible duplicates use the same date, direction, amount and normalized narration and are not removed automatically.",
f"Selected option: {selected_bank or 'auto'}. Manual selection still validates that the PDF matches the selected bank format.",
"Categories, counterparties and review notes are generic narration-based indicators and must be verified against books and source records.",
"This workbook is an analysis aid, not a substitute for ledger, GST, inventory, receivable/payable, cash-book and supporting records.",
"; ".join(meta.source_file for meta in metas),
],
}
)
with pd.ExcelWriter(output, engine="xlsxwriter", datetime_format="dd-mmm-yyyy") as writer:
dashboard.to_excel(writer, sheet_name="Dashboard", index=False)
recon.to_excel(writer, sheet_name="Statement Reconciliation", index=False)
all_export.to_excel(writer, sheet_name="All Extracted Rows", index=False)
unique_export.to_excel(writer, sheet_name="Unique Transactions", index=False)
all_export[all_df.exact_duplicate.to_numpy()].to_excel(writer, sheet_name="Exact Duplicates", index=False)
all_export[all_df.possible_duplicate.to_numpy()].to_excel(writer, sheet_name="Possible Duplicates", index=False)
monthly_summary(unique_df).to_excel(writer, sheet_name="Monthly Summary", index=False)
mode_summary(unique_df).to_excel(writer, sheet_name="Mode Summary", index=False)
category_summary(unique_df).to_excel(writer, sheet_name="Category Summary", index=False)
party_summary(unique_df).to_excel(writer, sheet_name="Party Summary", index=False)
review_items.to_excel(writer, sheet_name="Review Items", index=False)
draft_financials(unique_df, metas).to_excel(writer, sheet_name="Draft Financials", index=False)
duplicate_summary(all_df).to_excel(writer, sheet_name="Duplicate Summary", index=False)
notes.to_excel(writer, sheet_name="Assumptions", index=False)
workbook = writer.book
header = workbook.add_format({"bold": True, "bg_color": "#1F4E78", "font_color": "white", "border": 1})
money = workbook.add_format({"num_format": "#,##0.00"})
date_format = workbook.add_format({"num_format": "dd-mmm-yyyy"})
for name, worksheet in writer.sheets.items():
worksheet.freeze_panes(1, 0)
worksheet.set_row(0, 22, header)
max_col = max(0, worksheet.dim_colmax)
if worksheet.dim_rowmax >= 0:
worksheet.autofilter(0, 0, worksheet.dim_rowmax, max_col)
worksheet.set_column(0, max_col, 18)
writer.sheets["Dashboard"].set_column("A:A", 34)
writer.sheets["Dashboard"].set_column("B:B", 28)
writer.sheets["Dashboard"].set_column("C:C", 44)
for sheet in ("All Extracted Rows", "Unique Transactions", "Exact Duplicates", "Possible Duplicates", "Review Items"):
ws = writer.sheets[sheet]
ws.set_column("A:B", 13, date_format)
ws.set_column("C:C", 58)
ws.set_column("D:E", 30)
for sheet in ("Category Summary", "Party Summary", "Monthly Summary", "Mode Summary", "Draft Financials"):
writer.sheets[sheet].set_column(1, max(1, writer.sheets[sheet].dim_colmax), 18, money)
return output
@@ -1,3 +1,5 @@
from __future__ import annotations
from .idfc import IDFCFirstParser
from .axis import AxisParser
from .hdfc import HDFCParser
@@ -7,19 +9,72 @@ from .kotak import KotakParser
from .sbi import SBIModernParser, SBIOtherParser
from .base import extract_text
PARSERS=[IDFCFirstParser,AxisParser,HDFCParser,IndianBankModernParser,IndianBankLegacyParser,IndusIndParser,KotakParser,SBIOtherParser,SBIModernParser]
PARSERS = [
IDFCFirstParser,
AxisParser,
HDFCParser,
IndianBankModernParser,
IndianBankLegacyParser,
IndusIndParser,
KotakParser,
SBIOtherParser,
SBIModernParser,
]
BANK_OPTIONS = [
("auto", "Auto Detect"),
("axis", "Axis Bank"),
("hdfc", "HDFC Bank"),
("idfc", "IDFC FIRST Bank"),
("indian_bank", "Indian Bank"),
("indusind", "IndusInd Bank"),
("kotak", "Kotak Mahindra Bank"),
("sbi", "State Bank of India"),
]
BANK_PARSERS = {
"axis": [AxisParser],
"hdfc": [HDFCParser],
"idfc": [IDFCFirstParser],
"indian_bank": [IndianBankModernParser, IndianBankLegacyParser],
"indusind": [IndusIndParser],
"kotak": [KotakParser],
"sbi": [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
scored = sorted(((parser.detect(text), parser) for parser in PARSERS), key=lambda item: item[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):
def _selected_parser(bank_key: str, text: str):
candidates = BANK_PARSERS.get(bank_key, [])
if not candidates:
return None, 0
scored = sorted(((parser.detect(text), parser) for parser in candidates), key=lambda item: item[0], reverse=True)
if scored and scored[0][0] > 0:
return scored[0][1](), scored[0][0]
return None, 0
def parse_pdf(path, bank_hint: str | None = 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.')
hint = (bank_hint or "auto").strip().lower()
if hint and hint != "auto":
parser, score = _selected_parser(hint, text)
if parser is None:
label = dict(BANK_OPTIONS).get(hint, "the selected bank")
raise ValueError(
f"The uploaded statement does not match the selected {label} format. "
"Please verify the selected bank or choose Auto Detect."
)
return parser.parse(path, text)
parser, score = detect_parser(text)
if parser is None:
raise ValueError(
"Unsupported statement format. Select the bank manually or add a bank-specific parser for this statement layout."
)
return parser.parse(path, text)
@@ -3,17 +3,34 @@
<div class="mx-auto max-w-5xl space-y-6">
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1>
<p class="mt-2 text-sm text-slate-600">Upload one or more supported PDF bank statements. The analyzer prepares a reconciled Excel workbook with transaction summaries and duplicate checks.</p>
<p class="mt-2 text-sm text-slate-600">Upload one or more PDF bank statements. Select a bank for direct parser validation or keep Auto Detect. The workbook includes reconciliation, duplicate checks, transaction classifications, party summaries, review items and draft bank-basis financial helpers.</p>
</div>
{% if error %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"><strong>Analysis could not be completed.</strong><div class="mt-1">{{ error }}</div></div>{% endif %}
<form action="/tools/bank-statement-analyzer/analyze" method="post" enctype="multipart/form-data" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-5 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-semibold text-slate-700">Bank</label>
<select name="bank_selection" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
{% for value, label in bank_options %}<option value="{{ value }}" {% if selected_bank == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<p class="mt-1 text-xs text-slate-500">Manual selection validates the PDF against that bank. Auto Detect preserves the existing behavior.</p>
</div>
<div>
<label class="mb-1 block text-sm font-semibold text-slate-700">Financial year <span class="font-normal text-slate-400">(optional)</span></label>
<input name="financial_year" value="{{ financial_year or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Example: 2025-26">
</div>
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account holder override <span class="font-normal text-slate-400">(optional)</span></label><input name="customer_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account number override <span class="font-normal text-slate-400">(optional)</span></label><input name="account_number" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
</div>
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4">
<label class="flex items-start gap-3">
<input type="checkbox" name="enable_classification" value="1" {% if classification_enabled %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300">
<span><span class="block text-sm font-semibold text-slate-800">Enable narration-based transaction classification</span><span class="mt-1 block text-xs text-slate-500">Adds Category Summary, Party Summary, Review Items and Draft Financials. These are indicative classifications and require verification with books and supporting records.</span></span>
</label>
</div>
<div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Supported parsers</div><div class="mt-1">Axis Bank, HDFC Bank, IDFC FIRST Bank, Indian Bank, IndusInd Bank, Kotak Mahindra Bank and State Bank of India.</div><div class="mt-2 text-xs">Successful jobs are deleted automatically after the Excel response is sent. Failed or abandoned jobs are cleaned after the configured retention period.</div></div>
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Available banks</div><div class="mt-1">Axis Bank, HDFC Bank, IDFC FIRST Bank, Indian Bank, IndusInd Bank, Kotak Mahindra Bank and State Bank of India.</div><div class="mt-2 text-xs">Successful jobs are deleted automatically after the Excel response is sent. Failed or abandoned jobs are cleaned after the configured retention period.</div></div>
<div class="mt-6 flex flex-wrap gap-3"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Analyze Statements</button></div>
</form>
</div>
@@ -3,12 +3,13 @@
<div class="mx-auto max-w-5xl space-y-6">
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-6 shadow-soft"><h1 class="text-2xl font-bold text-emerald-900">Analysis completed</h1><p class="mt-2 text-sm text-emerald-800">Review the summary below and download the Excel workbook. The uploaded PDFs and temporary workbook are removed automatically after the download response completes.</p></div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{% for label, value in [('Statements', summary.statement_count), ('Rows extracted', summary.rows_extracted), ('Unique transactions', summary.unique_transactions), ('Exact duplicate rows', summary.exact_duplicate_rows), ('Possible duplicate rows', summary.possible_duplicate_rows)] %}
{% for label, value in [('Statements', summary.statement_count), ('Rows extracted', summary.rows_extracted), ('Unique transactions', summary.unique_transactions), ('Exact duplicate rows', summary.exact_duplicate_rows), ('Possible duplicate rows', summary.possible_duplicate_rows), ('Classification categories', summary.categories), ('Review items', summary.review_items)] %}
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-bold text-slate-900">{{ value }}</div></div>
{% endfor %}
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<dl class="grid gap-4 md:grid-cols-2"><div><dt class="text-xs font-semibold uppercase text-slate-500">Bank(s)</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ summary.banks|join(', ') }}</dd></div><div><dt class="text-xs font-semibold uppercase text-slate-500">Account holder</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ summary.customer_name or '-' }}</dd></div></dl>
<dl class="grid gap-4 md:grid-cols-2"><div><dt class="text-xs font-semibold uppercase text-slate-500">Bank(s)</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ summary.banks|join(', ') }}</dd></div><div><dt class="text-xs font-semibold uppercase text-slate-500">Account holder</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ summary.customer_name or '-' }}</dd></div><div><dt class="text-xs font-semibold uppercase text-slate-500">Financial year</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ summary.financial_year or '-' }}</dd></div><div><dt class="text-xs font-semibold uppercase text-slate-500">Classification</dt><dd class="mt-1 text-sm font-medium text-slate-900">{{ 'Enabled' if summary.classification_enabled else 'Disabled' }}</dd></div></dl>
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Workbook output</div><div class="mt-1">Dashboard, Statement Reconciliation, All Extracted Rows, Unique Transactions, Exact Duplicates, Possible Duplicates, Monthly Summary, Mode Summary, Category Summary, Party Summary, Review Items, Draft Financials, Duplicate Summary and Assumptions.</div></div>
<div class="mt-6 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/{{ summary.job_id }}/download" class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Download Analysis Excel</a><form action="/tools/bank-statement-analyzer/{{ summary.job_id }}/delete" method="post"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl border border-slate-300 px-5 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">Delete Without Download</button></form></div>
</div>
</div>
+57 -10
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import shutil
from pathlib import Path
from fastapi import APIRouter, File, Form, Request, UploadFile
@@ -13,6 +12,8 @@ from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from .parsers.registry import BANK_OPTIONS
from .service import can_use, create_job, save_uploads, analyze_job, resolve_owned_job, delete_job
router = APIRouter(prefix="/tools/bank-statement-analyzer", tags=["bank-statement-analyzer-ui"])
@@ -26,6 +27,10 @@ def _ctx(request, db, user, **extra):
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Bank Statement Analyzer",
"bank_options": BANK_OPTIONS,
"selected_bank": "auto",
"financial_year": "",
"classification_enabled": True,
}
data.update(extra)
return data
@@ -48,15 +53,29 @@ def index(request: Request):
user, roles, denied = _auth(request, db)
if denied:
return denied
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error=""))
return templates.TemplateResponse(
"modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html",
_ctx(request, db, user, error=""),
)
finally:
db.close()
@router.post("/analyze")
async def analyze(request: Request, csrf_token: str = Form(...), customer_name: str = Form(""), account_number: str = Form(""), statements: list[UploadFile] = File(...)):
async def analyze(
request: Request,
csrf_token: str = Form(...),
bank_selection: str = Form("auto"),
financial_year: str = Form(""),
customer_name: str = Form(""),
account_number: str = Form(""),
enable_classification: str | None = Form(None),
statements: list[UploadFile] = File(...),
):
db = CommonSessionLocal()
job_dir: Path | None = None
selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto"
classification_enabled = enable_classification == "1"
try:
user, roles, denied = _auth(request, db)
if denied:
@@ -65,16 +84,39 @@ async def analyze(request: Request, csrf_token: str = Form(...), customer_name:
job_id, input_dir, output_dir = create_job(user, roles)
job_dir = input_dir.parent
paths = await save_uploads(statements, input_dir)
summary = analyze_job(user=user, roles=roles, job_id=job_id, paths=paths, output_dir=output_dir, customer_override=customer_name, account_override=account_number)
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/result.html", _ctx(request, db, user, summary=summary))
summary = analyze_job(
user=user,
roles=roles,
job_id=job_id,
paths=paths,
output_dir=output_dir,
customer_override=customer_name,
account_override=account_number,
bank_selection=selected_bank,
financial_year=financial_year,
classification_enabled=classification_enabled,
)
return templates.TemplateResponse(
"modules/bank_statement_analyzer/templates/bank_statement_analyzer/result.html",
_ctx(request, db, user, summary=summary),
)
except Exception as exc:
if job_dir and job_dir.exists():
# Failed jobs are retained for the configured short retention period for troubleshooting/retry.
pass
user = get_current_user(request, db=db)
if not user:
return RedirectResponse("/login", status_code=303)
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error=str(exc)), status_code=400)
return templates.TemplateResponse(
"modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html",
_ctx(
request,
db,
user,
error=str(exc),
selected_bank=selected_bank,
financial_year=financial_year,
classification_enabled=classification_enabled,
),
status_code=400,
)
finally:
db.close()
@@ -93,7 +135,12 @@ def download(job_id: str, request: Request):
output = job / "Output" / meta["output_file"]
if not output.is_file():
return not_found_response(request, "Analysis workbook not found.")
return FileResponse(path=output, filename="Bank_Statement_Analysis.xlsx", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", background=BackgroundTask(delete_job, job))
return FileResponse(
path=output,
filename="Bank_Statement_Analysis.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
background=BackgroundTask(delete_job, job),
)
finally:
db.close()