Add comprehensive global bank transaction classification rules
This commit is contained in:
@@ -20,7 +20,9 @@ ANALYSIS_COLUMNS = [
|
|||||||
"duplicate_group_id", "duplicate_reason", "duplicate_confidence",
|
"duplicate_group_id", "duplicate_reason", "duplicate_confidence",
|
||||||
"transfer_bank_code", "transfer_reference", "transfer_comment",
|
"transfer_bank_code", "transfer_reference", "transfer_comment",
|
||||||
"auto_party", "party_match_method", "party_match_confidence",
|
"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",
|
"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:
|
def _normalize_party(value: str) -> str:
|
||||||
text = _text(value).upper()
|
text = _text(value).upper()
|
||||||
text = re.sub(r"\b(?:PVT\.?\s*LTD\.?|PRIVATE\s+LIMITED)\b", "PRIVATE LIMITED", text)
|
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]
|
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"))
|
narration = _text(row.get("narration"))
|
||||||
upper = narration.upper()
|
upper = narration.upper()
|
||||||
direction = row.get("direction") or _direction(row)
|
direction = row.get("direction") or _direction(row)
|
||||||
mode = str(row.get("mode") or "Other")
|
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:
|
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"):
|
if "CASH WITHDRAW" in upper or "ATM WDL" in upper or ("ATM/CASH" in upper and direction == "Debit"):
|
||||||
return "Cash Withdrawal", "Cash Withdrawal"
|
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 ("BANK CHARGE", "SERVICE CHARGE", "COMMISSION CHARGE", "IMPS COMMISSION", "SMS CHARGE")):
|
if any(token in upper for token in ("SELF", "OWN ACCOUNT", "SELF TRANSFER")) or ("TRANSFER FROM" in upper and "MOBILE TRANSFER" in upper):
|
||||||
return "Bank Charges", "Bank Charges"
|
return "Self Transfer / Contra", counterparty, "Contra", "Contra / Balance Sheet", "GLB-SELF-CONTRA", "SELF/OWN ACCOUNT", "Contra / Own Account", 80.0, True
|
||||||
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/"):
|
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:
|
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:
|
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:
|
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":
|
if "CTS-CHQ" in upper or "CLEARING" in upper or mode == "Cheque":
|
||||||
return ("Cheque Payment" if direction == "Debit" else "Cheque Deposit"), "Cheque Clearing"
|
category = "Cheque Payment" if direction == "Debit" else "Cheque Deposit"
|
||||||
if direction == "Debit":
|
return category, "Cheque Clearing", "Payment" if direction == "Debit" else "Receipt", _default_group(category, direction), "FALLBACK-CHEQUE", "CHEQUE/CLEARING", "Cheque Clearing", 55.0, True
|
||||||
return "Other Bank Payment / Review", _extract_counterparty(narration, direction)
|
category = "Other Bank Payment / Review" if direction == "Debit" else "Other Bank Receipt / Review"
|
||||||
return "Other Bank Receipt / Review", _extract_counterparty(narration, direction)
|
return category, counterparty, "Payment" if direction == "Debit" else "Receipt", "Unclassified", "FALLBACK-UNCLASSIFIED", "", "Unclassified / Review", 0.0, True
|
||||||
|
|
||||||
|
|
||||||
def _review_note(row) -> str:
|
def _review_note(row) -> str:
|
||||||
@@ -245,6 +351,8 @@ def _review_note(row) -> str:
|
|||||||
balance = row.get("balance")
|
balance = row.get("balance")
|
||||||
category = str(row.get("category") or "")
|
category = str(row.get("category") or "")
|
||||||
counterparty = str(row.get("counterparty") 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:
|
if amount >= HIGH_VALUE_THRESHOLD:
|
||||||
notes.append(f"High value transaction >= {HIGH_VALUE_THRESHOLD:,.0f}")
|
notes.append(f"High value transaction >= {HIGH_VALUE_THRESHOLD:,.0f}")
|
||||||
if category == "Cash Deposit / Cash Sales":
|
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])
|
x["transfer_comment"] = transfer.map(lambda t: t[2])
|
||||||
if classification_enabled:
|
if classification_enabled:
|
||||||
classified = x.apply(_classify_row, axis=1, result_type="expand")
|
classified = x.apply(_classify_row, axis=1, result_type="expand")
|
||||||
x["auto_category"] = classified[0]
|
classified.columns = [
|
||||||
x["auto_party"] = classified[1]
|
"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 = _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["category"] = x["auto_category"]
|
||||||
x["counterparty"] = x["auto_party"]
|
x["counterparty"] = x["auto_party"]
|
||||||
x["review_note"] = x.apply(_review_note, axis=1)
|
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["party_match_confidence"] = 0.0
|
||||||
x["auto_nature"] = x["direction"].map(lambda d: "Payment" if d == "Debit" else "Receipt")
|
x["auto_nature"] = x["direction"].map(lambda d: "Payment" if d == "Debit" else "Receipt")
|
||||||
x["auto_group"] = "Unclassified"
|
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["category"] = x["auto_category"]
|
||||||
x["counterparty"] = x["auto_party"]
|
x["counterparty"] = x["auto_party"]
|
||||||
x["review_note"] = x.apply(_review_note, axis=1)
|
x["review_note"] = x.apply(_review_note, axis=1)
|
||||||
@@ -449,7 +565,8 @@ def _workbook_columns(df):
|
|||||||
preferred = [
|
preferred = [
|
||||||
"transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference",
|
"transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference",
|
||||||
"transfer_comment", "auto_party", "party_match_method", "party_match_confidence",
|
"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",
|
"balance", "reference_no", "bank_name", "customer_name", "account_number", "source_file",
|
||||||
"source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id",
|
"source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id",
|
||||||
"duplicate_reason", "duplicate_confidence", "review_note",
|
"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()} | {
|
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",
|
"Advance Paid", "Advance Received", "Bank Charges", "Capital Introduction", "Cash Deposit / Cash Sales",
|
||||||
"Cash Withdrawal", "Customer / Business Receipt", "Drawings", "Fuel Expense", "Interest Paid",
|
"Cash Withdrawal", "Customer / Business Receipt", "Drawings", "Electricity Charges",
|
||||||
"Interest Received", "Loan Receipt", "Loan Repayment", "Purchase / Supplier Payment", "Refund / Reversal",
|
"Fuel & Vehicle Running Expenses", "Freight / Carriage Expenses", "Insurance Expenses",
|
||||||
"Rent Expense", "Salary Payment", "Self Transfer / Contra", "Tax / TDS Payment", "Unclassified / Review",
|
"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()})
|
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"]
|
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({
|
notes = pd.DataFrame({
|
||||||
"Assumption / Method": [
|
"Assumption / Method": [
|
||||||
"Python analysis engine", "Excel reporting engine", "Date parsing", "Duplicate detection",
|
"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": [
|
"Details": [
|
||||||
"PDF extraction, transaction reconstruction, duplicate detection, references, comments and automatic classification are performed in Python.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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),
|
"; ".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:
|
with pd.ExcelWriter(output, engine="xlsxwriter", datetime_format="dd-mmm-yyyy", engine_kwargs={"options": {"strings_to_formulas": True}}) as writer:
|
||||||
workbook = writer.book
|
workbook = writer.book
|
||||||
workbook.set_calc_mode("auto")
|
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)
|
exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False)
|
||||||
possible_export.to_excel(writer, sheet_name="Possible 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)
|
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)
|
notes.to_excel(writer, sheet_name="Assumptions", index=False)
|
||||||
|
|
||||||
# Masters first so validation ranges exist.
|
# 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.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.freeze_panes(1, 4)
|
||||||
ws_tx.set_column(col_index["narration"], col_index["narration"], 60)
|
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)
|
ws_tx.set_column(col_index[c], col_index[c], 28)
|
||||||
for c in ("debit", "credit", "balance"):
|
for c in ("debit", "credit", "balance"):
|
||||||
ws_tx.set_column(col_index[c], col_index[c], 15, money)
|
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("A:A", 28)
|
||||||
writer.sheets["Assumptions"].set_column("B:B", 90)
|
writer.sheets["Assumptions"].set_column("B:B", 90)
|
||||||
writer.sheets["Masters"].set_column("A:D", 34)
|
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()
|
writer.sheets["Masters"].hide()
|
||||||
return output
|
return output
|
||||||
|
|||||||
Reference in New Issue
Block a user