diff --git a/app/modules/bank_statement_analyzer/analyzer.py b/app/modules/bank_statement_analyzer/analyzer.py index 601eeb9..e1af724 100644 --- a/app/modules/bank_statement_analyzer/analyzer.py +++ b/app/modules/bank_statement_analyzer/analyzer.py @@ -20,7 +20,9 @@ ANALYSIS_COLUMNS = [ "duplicate_group_id", "duplicate_reason", "duplicate_confidence", "transfer_bank_code", "transfer_reference", "transfer_comment", "auto_party", "party_match_method", "party_match_confidence", - "auto_category", "auto_nature", "auto_group", "review_note", + "auto_category", "auto_nature", "auto_group", "matched_rule_id", + "matched_keyword", "suggested_ledger", "rule_confidence", + "review_required", "review_note", "category", "counterparty", ] @@ -81,6 +83,104 @@ TRANSFER_PATTERNS = { } +def _rx(pattern: str) -> re.Pattern: + return re.compile(pattern, re.IGNORECASE) + + +# Phase 1 global narration rules. Specific rules must appear before generic rules. +# Every rule remains reviewable in Excel through the manual override columns. +GLOBAL_CLASSIFICATION_RULES = [ + # Google sub-rules must precede generic Google/software rules. + {"id": "GLB-GOOGLE-TOLL", "pattern": _rx(r"\b(?:GPAY[- ]?TOLL|FASTAG|TOLL(?:\s+PLAZA)?|PARKING)\b"), "debit": "Toll & Parking Expenses", "credit": "Toll / Parking Refund", "ledger": "Toll & Parking Expenses", "confidence": 98.0}, + {"id": "GLB-GOOGLE-INTERNET", "pattern": _rx(r"\b(?:GOOGLEBBPSINTERNET|GOOGLE.*INTERNET BILL)\b"), "debit": "Telephone & Internet Expenses", "credit": "Telephone / Internet Refund", "ledger": "Telephone & Internet Expenses", "confidence": 97.0}, + {"id": "GLB-GOOGLE-REFUND", "pattern": _rx(r"\b(?:GPAYONLINEREFUNDS|GOOGLE.*REFUND|REFUND.*GOOGLE)\b"), "debit": "Refund Paid / Review", "credit": "Refund / Reversal Receipt", "ledger": "Refunds & Reversals", "confidence": 98.0, "review": True}, + + # Bank charges and bank interest. + {"id": "GLB-BANK-CHARGES", "pattern": _rx(r"\b(?:ATM\s*(?:WDL|ENQ)?\s*CHARGES?|ATM\s*AMC|FOLIO\s*CHARGES?|IMPS\s*COMMISSION\s*CHARGES?|NEFT\s*CHARGES?|RTGS\s*CHARGES?|SMS\s*CHARGES?|BANK\s*CHARGES?|SERVICE\s*CHARGES?|CHEQUE\s*RETURN\s*CHARGES?|BOUNCE\s*CHARGES?|RETURN\s*CHARGES?)\b"), "debit": "Bank Charges", "credit": "Bank Charges Reversal", "ledger": "Bank Charges", "confidence": 99.0}, + {"id": "GLB-BANK-INTEREST-DEBIT", "pattern": _rx(r"\b(?:DEBIT\s*INTEREST|INTEREST\s*DEBITED|OD\s*INTEREST|CC\s*INTEREST)\b"), "debit": "Interest on Bank / OD / CC", "credit": "Interest Reversal", "ledger": "Interest on Bank Borrowings", "confidence": 99.0}, + {"id": "GLB-BANK-INTEREST-CREDIT", "pattern": _rx(r"\b(?:CREDIT\s*INTEREST|INTEREST\s*CREDITED|SB\s*INTEREST)\b"), "debit": "Interest Reversal", "credit": "Interest Income", "ledger": "Interest Income", "confidence": 99.0}, + + # Telecom, utilities and vehicle operating costs. + {"id": "GLB-TELECOM", "pattern": _rx(r"\b(?:AIRTEL|VODAFONE|VI\s*(?:PREPAID|POSTPAID)?|RELIANCE\s*JIO|JIOFIBER|BSNL|HATHWAY|AIRTEL\s*XSTREAM|ACT\s*FIBERNET|POSTPAID|BROADBAND)\b"), "debit": "Telephone & Internet Expenses", "credit": "Telephone / Internet Refund", "ledger": "Telephone & Internet Expenses", "confidence": 96.0}, + {"id": "GLB-ELECTRICITY", "pattern": _rx(r"\b(?:TNEB|TANGEDCO|BESCOM|KSEB|MSEB|APSPDCL|ELECTRICITY|POWER\s*BILL|EB\s*BILL)\b"), "debit": "Electricity Charges", "credit": "Electricity Deposit / Refund", "ledger": "Electricity Charges", "confidence": 98.0}, + {"id": "GLB-FUEL", "pattern": _rx(r"\b(?:BPCL|HPCL|INDIAN\s*OIL|IOC|SHELL|NAYARA|PETROL|DIESEL|FUEL|CNG)\b"), "debit": "Fuel & Vehicle Running Expenses", "credit": "Fuel Refund / Reimbursement", "ledger": "Fuel & Vehicle Running Expenses", "confidence": 96.0}, + {"id": "GLB-VEHICLE-REPAIR", "pattern": _rx(r"\b(?:TYRE|TIRE|BATTERY|SPARES?|GARAGE|WORKSHOP|MECHANIC|VEHICLE\s*REPAIR|MOTOR\s*SERVICE|AUTO\s*SERVICE)\b"), "debit": "Repairs & Maintenance - Vehicle", "credit": "Vehicle Repair Refund", "ledger": "Repairs & Maintenance - Vehicle", "confidence": 94.0}, + + # Travel, accommodation, food and personal-review merchants. + {"id": "GLB-TRAVEL", "pattern": _rx(r"\b(?:IRCTC|INDIGO|AIR\s*INDIA|AKASA|SPICEJET|VISTARA|UBER|OLA|RAPIDO|REDBUS)\b"), "debit": "Travelling Expenses", "credit": "Travel Cancellation Refund", "ledger": "Travelling Expenses", "confidence": 96.0}, + {"id": "GLB-ACCOMMODATION-BRAND", "pattern": _rx(r"\b(?:OYO|FABHOTELS?|MARRIOTT|TAJ\s+HOTELS?|LEMON\s+TREE|HOLIDAY\s+INN|ACCOMMODATION|LODGING)\b"), "debit": "Travelling & Accommodation", "credit": "Accommodation Refund", "ledger": "Travelling & Accommodation", "confidence": 97.0}, + {"id": "GLB-ACCOMMODATION-GENERIC", "pattern": _rx(r"\bHOTEL\b"), "debit": "Travelling & Accommodation", "credit": "Accommodation / Restaurant Refund", "ledger": "Travelling & Accommodation", "confidence": 70.0, "review": True}, + {"id": "GLB-FOOD", "pattern": _rx(r"\b(?:SWIGGY|ZOMATO|EATCLUB|RESTAURANT|CAFE|FOOD\s*COURT)\b"), "debit": "Staff Welfare / Possible Personal Expense", "credit": "Food Order Refund", "ledger": "Staff Welfare / Personal Review", "confidence": 70.0, "review": True}, + {"id": "GLB-MEDICAL", "pattern": _rx(r"\b(?:PHARMACY|MEDPLUS|APOLLO\s*PHARMACY|HOSPITAL|CLINIC|MEDICAL)\b"), "debit": "Medical / Possible Personal Expense", "credit": "Medical Refund / Reimbursement", "ledger": "Medical / Personal Review", "confidence": 75.0, "review": True}, + {"id": "GLB-ENTERTAINMENT", "pattern": _rx(r"\b(?:NETFLIX|JIOHOTSTAR|HOTSTAR|PRIME\s*VIDEO|SONYLIV|ZEE5|PVR|INOX)\b"), "debit": "Subscription / Possible Personal Expense", "credit": "Subscription Refund", "ledger": "Subscriptions / Personal Review", "confidence": 75.0, "review": True}, + {"id": "GLB-RETAIL-REVIEW", "pattern": _rx(r"\b(?:DMART|D[- ]?MART|RELIANCE\s*SMART|SUPERMARKET|SHOPPING\s*MALL|LIFESTYLE)\b"), "debit": "General Purchase / Possible Personal Expense", "credit": "Retail Refund", "ledger": "General Purchases / Personal Review", "confidence": 68.0, "review": True}, + + # Insurance and subscriptions. + {"id": "GLB-INSURANCE", "pattern": _rx(r"\b(?:INSURANCE|PREMIUM|LIC|HDFC\s*ERGO|ICICI\s*LOMBARD|STAR\s*HEALTH|NIVA\s*BUPA|UNIVERSAL\s*SOMPO)\b"), "debit": "Insurance Expenses", "credit": "Insurance Claim / Refund", "ledger": "Insurance Expenses", "confidence": 96.0}, + {"id": "GLB-HOSTING", "pattern": _rx(r"\b(?:HOSTINGER|GODADDY|NAMECHEAP|CLOUDFLARE|BIGROCK|RESELLERCLUB)\b"), "debit": "Website & Hosting Expenses", "credit": "Hosting Refund", "ledger": "Website & Hosting Expenses", "confidence": 98.0}, + {"id": "GLB-SOFTWARE", "pattern": _rx(r"\b(?:AMAZON\s*WEB\s*SERVICES|AWS|AZURE|GOOGLE\s*CLOUD|DIGITALOCEAN|LINODE|VULTR|HETZNER|OPENAI|CHATGPT|ANTHROPIC|CLAUDE|GITHUB|ATLASSIAN|SLACK|ZOOM|CANVA|ADOBE|FIGMA|GOOGLE\s*PLAY)\b"), "debit": "Software Subscription", "credit": "Software / Subscription Refund", "ledger": "Software Subscription", "confidence": 95.0}, + {"id": "GLB-COURIER", "pattern": _rx(r"\b(?:DTDC|BLUE\s*DART|DELHIVERY|PROFESSIONAL\s*COURIER|INDIA\s*POST|COURIER)\b"), "debit": "Courier & Postage", "credit": "Courier Refund", "ledger": "Courier & Postage", "confidence": 97.0}, + + # Employment, occupancy and financing. + {"id": "GLB-SALARY", "pattern": _rx(r"\b(?:SALARY|WAGES|PAYROLL)\b"), "debit": "Salary & Wages", "credit": "Salary Reversal / Employee Recovery", "ledger": "Salary & Wages", "confidence": 95.0}, + {"id": "GLB-RENT", "pattern": _rx(r"\b(?:RENT|LEASE|RENTAL)\b"), "debit": "Rent Expense", "credit": "Rent Receipt", "ledger": "Rent Expense / Rent Income", "confidence": 92.0}, + {"id": "GLB-LOAN-EMI", "pattern": _rx(r"\b(?:LOAN\s*EMI|BAJAJ\s*EMI|EMI|NACH|ECS|AUTO\s*DEBIT)\b"), "debit": "Loan Repayment", "credit": "Loan Receipt / Funding", "ledger": "Loan Account", "confidence": 82.0, "review": True, "group": "Contra / Balance Sheet"}, + {"id": "GLB-CREDIT-CARD", "pattern": _rx(r"\b(?:CREDIT\s*CARD\s*PAYMENT|CARD\s*PAYMENT|CC\s*PAYMENT)\b"), "debit": "Credit Card Payment / Contra", "credit": "Credit Card Refund / Reversal", "ledger": "Credit Card Account", "confidence": 90.0, "review": True, "group": "Contra / Balance Sheet"}, + + # Taxes and statutory payments. + {"id": "GLB-GST", "pattern": _rx(r"\b(?:GST\s*PMT|GSTN|GST\s*PAYMENT|CPIN)\b"), "debit": "GST Payment", "credit": "GST Refund / Receipt", "ledger": "GST Payable / Receivable", "confidence": 98.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-INCOME-TAX", "pattern": _rx(r"\b(?:INCOME\s*TAX|ITNS|OLTAS|ADVANCE\s*TAX|SELF\s*ASSESSMENT\s*TAX)\b"), "debit": "Income Tax Payment", "credit": "Income Tax Refund", "ledger": "Income Tax", "confidence": 98.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-TDS", "pattern": _rx(r"\b(?:TDS|CHALLAN\s*281|TRACES)\b"), "debit": "TDS Payment", "credit": "TDS Refund / Reversal", "ledger": "TDS Payable / Receivable", "confidence": 98.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-PF", "pattern": _rx(r"\b(?:EPFO|PROVIDENT\s*FUND|PF\s*PAYMENT)\b"), "debit": "Provident Fund Payment", "credit": "Provident Fund Refund", "ledger": "Provident Fund Payable", "confidence": 97.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-ESIC", "pattern": _rx(r"\b(?:ESIC|ESI\s*PAYMENT)\b"), "debit": "ESIC Payment", "credit": "ESIC Refund", "ledger": "ESIC Payable", "confidence": 97.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-PROF-TAX", "pattern": _rx(r"\b(?:PROFESSIONAL\s*TAX|PROF\s*TAX|PTAX)\b"), "debit": "Professional Tax Payment", "credit": "Professional Tax Refund", "ledger": "Professional Tax Payable", "confidence": 97.0, "group": "Contra / Balance Sheet"}, + {"id": "GLB-MCA-ROC", "pattern": _rx(r"\b(?:MCA|ROC\s*FEE|REGISTRAR\s*OF\s*COMPANIES)\b"), "debit": "ROC / MCA Filing Fees", "credit": "ROC / MCA Refund", "ledger": "ROC / MCA Filing Fees", "confidence": 96.0}, + + # Transport, handling, advances and commission. + {"id": "GLB-FREIGHT", "pattern": _rx(r"\b(?:FREIGHT|LR\s*PAYMENT|LORRY\s*RECEIPT|CARRIAGE|TRANSPORT\s*(?:CHARGE|PAYMENT|RECEIPT))\b"), "debit": "Freight / Carriage Expenses", "credit": "Freight / Transport Receipt", "ledger": "Freight / Carriage", "confidence": 88.0, "review": True}, + {"id": "GLB-HANDLING", "pattern": _rx(r"\b(?:LOADING|UNLOADING|HAMALI|HANDLING\s*CHARGES?)\b"), "debit": "Loading & Unloading Charges", "credit": "Loading / Handling Receipt", "ledger": "Loading & Unloading Charges", "confidence": 93.0}, + {"id": "GLB-ADVANCE", "pattern": _rx(r"\b(?:VEHICLE\s*ADVANCE|DRIVER\s*ADVANCE|TRIP\s*ADVANCE|DIESEL\s*ADVANCE|ADVANCE)\b"), "debit": "Advance Paid", "credit": "Advance Received", "ledger": "Advances", "confidence": 72.0, "review": True, "group": "Contra / Balance Sheet"}, + {"id": "GLB-COMMISSION", "pattern": _rx(r"\b(?:COMMISSION|BROKERAGE|AGENT\s*COMMISSION)\b"), "debit": "Commission & Brokerage Expense", "credit": "Commission Income", "ledger": "Commission & Brokerage", "confidence": 90.0, "review": True}, + + # Refunds, reversals and failed transactions. + {"id": "GLB-REFUND", "pattern": _rx(r"\b(?:REFUND|REVERSAL|RVSL|REVERSED|CANCELLED|CANCELLATION)\b"), "debit": "Refund Paid / Review", "credit": "Refund / Reversal Receipt", "ledger": "Refunds & Reversals", "confidence": 92.0, "review": True}, + {"id": "GLB-DISHONOUR", "pattern": _rx(r"\b(?:CHEQUE\s*RETURN|BOUNCE|DISHONOURED|FAILED\s*TRANSACTION|RETURNED)\b"), "debit": "Dishonour / Returned Transaction", "credit": "Dishonour / Returned Transaction", "ledger": "Dishonoured Transactions", "confidence": 91.0, "review": True}, + + # Payment gateway and marketplace settlement rules. + {"id": "GLB-PAYMENT-GATEWAY", "pattern": _rx(r"\b(?:RAZORPAY|CASHFREE|PAYU|CCAVENUE|PHONEPE\s*PG)\b"), "debit": "Payment Gateway Charges / Settlement Adjustment", "credit": "Payment Gateway Settlement", "ledger": "Payment Gateway Settlement", "confidence": 88.0, "review": True}, + {"id": "GLB-MARKETPLACE", "pattern": _rx(r"\b(?:AMAZON\s*SELLER|FLIPKART\s*SELLER|MEESHO|MYNTRA|AJIO)\b"), "debit": "Marketplace Charges / Review", "credit": "Sales Receipt / Marketplace Settlement", "ledger": "Marketplace Settlement", "confidence": 88.0, "review": True}, +] + + +def _default_group(category: str, direction: str) -> str: + if any(token in category for token in ("Contra", "Loan", "Advance", "GST", "Income Tax", "TDS", "Provident Fund", "ESIC", "Professional Tax")): + return "Contra / Balance Sheet" + if direction == "Credit" or any(token in category for token in ("Receipt", "Income", "Refund", "Deposit", "Settlement")): + return "Income / Receipt" + if category.endswith("/ Review") or "Possible Personal" in category: + return "Unclassified" + return "Expense / Payment" + + +def _apply_global_rule(narration: str, direction: str) -> dict | None: + searchable = _text(narration).upper() + for rule in GLOBAL_CLASSIFICATION_RULES: + match = rule["pattern"].search(searchable) + if not match: + continue + category = rule["debit"] if direction == "Debit" else rule["credit"] + return { + "category": category, + "rule_id": rule["id"], + "keyword": match.group(0), + "ledger": rule["ledger"], + "confidence": float(rule.get("confidence", 90.0)), + "review": bool(rule.get("review", False)), + "group": rule.get("group") or _default_group(category, direction), + } + return None + + def _normalize_party(value: str) -> str: text = _text(value).upper() text = re.sub(r"\b(?:PVT\.?\s*LTD\.?|PRIVATE\s+LIMITED)\b", "PRIVATE LIMITED", text) @@ -194,49 +294,55 @@ def _extract_counterparty(narration: str, direction: str) -> str: return raw[:120] -def _classify_row(row) -> tuple[str, str]: +def _classify_row(row) -> tuple[str, str, str, str, str, str, str, float, bool]: narration = _text(row.get("narration")) upper = narration.upper() direction = row.get("direction") or _direction(row) mode = str(row.get("mode") or "Other") + counterparty = _extract_counterparty(narration, direction) + + # Nominal credits are often bank-account validation entries rather than income. + amount = float(row.get("amount") or 0) + if direction == "Credit" and 0 < amount <= 10: + category = "Bank Account Validation / Test Transaction" + return category, counterparty, "Receipt", "Unclassified", "GLB-NOMINAL-VALIDATION", f"Amount {amount:,.2f}", "Validation / Test Transaction", 75.0, True + + # Strong global rules take priority over generic mode classifications. + matched = _apply_global_rule(narration, direction) + if matched: + nature = "Payment" if direction == "Debit" else "Receipt" + if matched["group"] == "Contra / Balance Sheet": + nature = "Contra / Balance Sheet" + elif "Refund" in matched["category"] or "Reversal" in matched["category"]: + nature = "Refund / Reversal" + return ( + matched["category"], counterparty, nature, matched["group"], matched["rule_id"], + matched["keyword"], matched["ledger"], matched["confidence"], matched["review"], + ) if "CASH DEP" in upper or "CASH DEPOSIT" in upper: - return "Cash Deposit / Cash Sales", "Cash Deposit" + return "Cash Deposit / Cash Sales", "Cash Deposit", "Receipt", "Income / Receipt", "GLB-CASH-DEPOSIT", "CASH DEP", "Cash / Sales Receipts", 95.0, True 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) + return "Cash Withdrawal", "Cash Withdrawal", "Payment", "Contra / Balance Sheet", "GLB-CASH-WITHDRAWAL", "ATM/CASH", "Cash Account", 98.0, True + 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", counterparty, "Contra", "Contra / Balance Sheet", "GLB-SELF-CONTRA", "SELF/OWN ACCOUNT", "Contra / Own Account", 80.0, True 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) + category = "UPI Payment / Expense" if direction == "Debit" else "UPI Customer Receipt" + return category, counterparty, "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-UPI", "UPI", "UPI - Manual Classification", 45.0, True if mode == "NEFT" or "NEFT" in upper: - return ("NEFT Payment" if direction == "Debit" else "NEFT Receipt"), _extract_counterparty(narration, direction) + category = "NEFT Payment" if direction == "Debit" else "NEFT Receipt" + return category, counterparty, "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-NEFT", "NEFT", "NEFT - Manual Classification", 50.0, True if mode == "RTGS" or "RTGS" in upper: - return ("RTGS Payment" if direction == "Debit" else "RTGS Receipt"), _extract_counterparty(narration, direction) + category = "RTGS Payment" if direction == "Debit" else "RTGS Receipt" + return category, counterparty, "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-RTGS", "RTGS", "RTGS - Manual Classification", 50.0, True if mode == "IMPS" or "IMPS" in upper: - return ("IMPS Payment" if direction == "Debit" else "IMPS Receipt"), _extract_counterparty(narration, direction) + category = "IMPS Payment" if direction == "Debit" else "IMPS Receipt" + return category, counterparty, "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-IMPS", "IMPS", "IMPS - Manual Classification", 50.0, True 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) + category = "Cheque Payment" if direction == "Debit" else "Cheque Deposit" + return category, "Cheque Clearing", "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-CHEQUE", "CHEQUE/CLEARING", "Cheque Clearing", 55.0, True + category = "Other Bank Payment / Review" if direction == "Debit" else "Other Bank Receipt / Review" + return category, counterparty, "Payment" if direction == "Debit" else "Receipt", "Unclassified", "FALLBACK-UNCLASSIFIED", "", "Unclassified / Review", 0.0, True def _review_note(row) -> str: @@ -245,6 +351,8 @@ def _review_note(row) -> str: balance = row.get("balance") category = str(row.get("category") or "") counterparty = str(row.get("counterparty") or "") + if bool(row.get("review_required")): + notes.append("Global rule requires manual review") if amount >= HIGH_VALUE_THRESHOLD: notes.append(f"High value transaction >= {HIGH_VALUE_THRESHOLD:,.0f}") if category == "Cash Deposit / Cash Sales": @@ -291,11 +399,14 @@ def enrich(df, classification_enabled: bool = True): x["transfer_comment"] = transfer.map(lambda t: t[2]) if classification_enabled: classified = x.apply(_classify_row, axis=1, result_type="expand") - x["auto_category"] = classified[0] - x["auto_party"] = classified[1] + classified.columns = [ + "auto_category", "auto_party", "auto_nature", "auto_group", + "matched_rule_id", "matched_keyword", "suggested_ledger", + "rule_confidence", "review_required", + ] + for column in classified.columns: + x[column] = classified[column] x = _apply_party_grouping(x) - x["auto_nature"] = x["direction"].map(lambda d: "Payment" if d == "Debit" else "Receipt") - x["auto_group"] = x["auto_category"].map(lambda c: "Contra / Balance Sheet" if c in {"Self Transfer / Contra", "Loan Receipt", "Loan Repayment", "Capital Introduction", "Drawings"} else ("Income / Receipt" if any(k in c for k in ("Receipt", "Deposit", "Interest Received", "Settlement")) else "Expense / Payment")) x["category"] = x["auto_category"] x["counterparty"] = x["auto_party"] x["review_note"] = x.apply(_review_note, axis=1) @@ -306,6 +417,11 @@ def enrich(df, classification_enabled: bool = True): x["party_match_confidence"] = 0.0 x["auto_nature"] = x["direction"].map(lambda d: "Payment" if d == "Debit" else "Receipt") x["auto_group"] = "Unclassified" + x["matched_rule_id"] = "CLASSIFICATION-DISABLED" + x["matched_keyword"] = "" + x["suggested_ledger"] = "" + x["rule_confidence"] = 0.0 + x["review_required"] = True x["category"] = x["auto_category"] x["counterparty"] = x["auto_party"] x["review_note"] = x.apply(_review_note, axis=1) @@ -449,7 +565,8 @@ def _workbook_columns(df): preferred = [ "transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference", "transfer_comment", "auto_party", "party_match_method", "party_match_confidence", - "auto_category", "auto_nature", "auto_group", "mode", "direction", "debit", "credit", + "auto_category", "auto_nature", "auto_group", "matched_rule_id", "matched_keyword", + "suggested_ledger", "rule_confidence", "review_required", "mode", "direction", "debit", "credit", "balance", "reference_no", "bank_name", "customer_name", "account_number", "source_file", "source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id", "duplicate_reason", "duplicate_confidence", "review_note", @@ -498,9 +615,15 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b categories = sorted({str(v) for v in tx.get("auto_category", pd.Series(dtype=str)).dropna() if str(v).strip()} | { "Advance Paid", "Advance Received", "Bank Charges", "Capital Introduction", "Cash Deposit / Cash Sales", - "Cash Withdrawal", "Customer / Business Receipt", "Drawings", "Fuel Expense", "Interest Paid", - "Interest Received", "Loan Receipt", "Loan Repayment", "Purchase / Supplier Payment", "Refund / Reversal", - "Rent Expense", "Salary Payment", "Self Transfer / Contra", "Tax / TDS Payment", "Unclassified / Review", + "Cash Withdrawal", "Customer / Business Receipt", "Drawings", "Electricity Charges", + "Fuel & Vehicle Running Expenses", "Freight / Carriage Expenses", "Insurance Expenses", + "Interest Income", "Interest on Bank / OD / CC", "Loading & Unloading Charges", + "Loan Receipt / Funding", "Loan Repayment", "Medical / Possible Personal Expense", + "Purchase / Supplier Payment", "Refund / Reversal Receipt", "Rent Expense", + "Repairs & Maintenance - Vehicle", "Salary & Wages", "Self Transfer / Contra", + "Software Subscription", "Staff Welfare / Possible Personal Expense", + "Telephone & Internet Expenses", "Toll & Parking Expenses", "Travelling & Accommodation", + "Travelling Expenses", "Website & Hosting Expenses", "Unclassified / Review", }) parties = sorted({str(v) for v in tx.get("auto_party", pd.Series(dtype=str)).dropna() if str(v).strip()}) natures = ["Receipt", "Payment", "Contra", "Loan", "Capital", "Refund", "Unclassified"] @@ -509,7 +632,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b notes = pd.DataFrame({ "Assumption / Method": [ "Python analysis engine", "Excel reporting engine", "Date parsing", "Duplicate detection", - "Party grouping", "Transfer comments", "Manual overrides", "Trial balance limitation", "Files analysed", + "Party grouping", "Transfer comments", "Global rules", "Manual overrides", "Trial balance limitation", "Files analysed", ], "Details": [ "PDF extraction, transaction reconstruction, duplicate detection, references, comments and automatic classification are performed in Python.", @@ -518,12 +641,28 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b "Exact and possible duplicate flags are Python-generated. Formula summaries exclude exact duplicates because Transaction Classification contains one retained row per exact group.", "Party names are normalized using regex cleanup and conservative fuzzy/partial matching. Review suggested groupings before finalisation.", "NEFT, RTGS and IMPS bank codes, references and narration comments are preserved in dedicated columns.", + "Phase 1 classification uses deterministic global regex rules. Matched Rule ID, keyword, suggested ledger, confidence and review requirement are preserved for audit traceability.", "Enter corrections only in Manual Party, Manual Category, Manual Nature, Manual Group and Manual Review Note. Final columns use Excel formulas.", "The Trial Balance is a bank-movement working paper, not a final accounting trial balance. Verify with ledgers, invoices, GST, loans, capital and supporting records.", "; ".join(meta.source_file for meta in metas), ], }) + rules_export = pd.DataFrame([ + { + "Rule ID": rule["id"], + "Priority": position + 1, + "Regex Pattern": rule["pattern"].pattern, + "Debit Category": rule["debit"], + "Credit Category": rule["credit"], + "Suggested Ledger": rule["ledger"], + "Confidence": float(rule.get("confidence", 90.0)), + "Review Required": bool(rule.get("review", False)), + "Group Override": rule.get("group", ""), + } + for position, rule in enumerate(GLOBAL_CLASSIFICATION_RULES) + ]) + with pd.ExcelWriter(output, engine="xlsxwriter", datetime_format="dd-mmm-yyyy", engine_kwargs={"options": {"strings_to_formulas": True}}) as writer: workbook = writer.book workbook.set_calc_mode("auto") @@ -544,6 +683,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False) possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False) duplicate_summary(all_df).to_excel(writer, sheet_name="Duplicate Summary", index=False) + rules_export.to_excel(writer, sheet_name="Classification Rules", index=False) notes.to_excel(writer, sheet_name="Assumptions", index=False) # Masters first so validation ranges exist. @@ -592,7 +732,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b ws_tx.add_table(0, 0, len(tx), len(columns)-1, {"name": "tblTransactions", "columns": [{"header": c} for c in columns], "style": "Table Style Medium 2"}) ws_tx.freeze_panes(1, 4) ws_tx.set_column(col_index["narration"], col_index["narration"], 60) - for c in ("transfer_comment", "auto_party", "manual_party", "final_party", "auto_category", "manual_category", "final_category", "review_note", "manual_review_note"): + for c in ("transfer_comment", "auto_party", "manual_party", "final_party", "auto_category", "manual_category", "final_category", "matched_rule_id", "matched_keyword", "suggested_ledger", "review_note", "manual_review_note"): ws_tx.set_column(col_index[c], col_index[c], 28) for c in ("debit", "credit", "balance"): ws_tx.set_column(col_index[c], col_index[c], 15, money) @@ -751,5 +891,13 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b writer.sheets["Assumptions"].set_column("A:A", 28) writer.sheets["Assumptions"].set_column("B:B", 90) writer.sheets["Masters"].set_column("A:D", 34) + ws_rules = writer.sheets.get("Classification Rules") + if ws_rules: + ws_rules.freeze_panes(1, 0) + ws_rules.set_column("A:B", 18) + ws_rules.set_column("C:C", 52) + ws_rules.set_column("D:F", 32) + ws_rules.set_column("G:I", 18) + writer.sheets["Masters"].hide() return output