""" Merchant Classifier — LLM-powered UPI merchant identification with caching. Flow: 1. Extract UPI handle from transaction description 2. Look up in SQLite merchant DB → return if found 3. If not found, call LLM to classify → store in DB → return 4. DB acts as persistent cache — LLM called only once per new merchant DB tables: - merchants: upi_handle → display_name, category, is_income, confidence - merchant_aliases: canonical_name → upi_handle (for dedup) """ import sqlite3 import re import json from pathlib import Path from typing import Optional, Tuple DB_PATH = Path(__file__).parent.parent.parent / "data" / "merchants.db" # Persist outside of rsync path so deploys don't wipe it def get_merchant(upi_handle: str) -> Optional[dict]: """Look up a UPI handle in the merchant database.""" conn = None try: conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row row = conn.execute( "SELECT * FROM merchants WHERE upi_handle = ?", (upi_handle,) ).fetchone() return dict(row) if row else None except sqlite3.OperationalError: return None finally: if conn is not None: conn.close() def normalize_description_key(desc: str) -> str: """Normalize description for rule matching: uppercase, strip digits, collapse whitespace.""" if not desc: return "" text = str(desc).upper().strip() text = re.sub(r'\d+', '', text) text = re.sub(r'\s+', ' ', text).strip() # Guard against very short keys that could match unrelated transactions if len(text) < 5: return "" return text[:200] def _ensure_description_rules_table(conn): conn.execute( """ CREATE TABLE IF NOT EXISTS description_rules ( description_key TEXT PRIMARY KEY, category TEXT NOT NULL, is_income INTEGER DEFAULT 0, confidence REAL DEFAULT 0.90, sample_desc TEXT, created_at TEXT ) """ ) def store_description_rule(desc: str, category: str, is_income: bool, confidence: float = 0.90) -> bool: key = normalize_description_key(desc) if not key: return False conn = sqlite3.connect(str(DB_PATH)) try: _ensure_description_rules_table(conn) import datetime now_str = datetime.datetime.now(datetime.timezone.utc).isoformat() conn.execute( """INSERT OR REPLACE INTO description_rules (description_key, category, is_income, confidence, sample_desc, created_at) VALUES (?, ?, ?, ?, ?, ?)""", (key, category, 1 if is_income else 0, confidence, desc[:200], now_str) ) conn.commit() return True except Exception as e: print(f"Error storing description rule {key}: {e}") return False finally: conn.close() def get_description_rule(desc: str) -> Optional[dict]: key = normalize_description_key(desc) if not key: return None conn = None try: conn = sqlite3.connect(str(DB_PATH)) _ensure_description_rules_table(conn) conn.row_factory = sqlite3.Row row = conn.execute( "SELECT * FROM description_rules WHERE description_key = ?", (key,) ).fetchone() return dict(row) if row else None except Exception: return None finally: if conn is not None: conn.close() def extract_upi_handle(description: str) -> Optional[str]: """Extract the merchant/counterparty handle from an Indian bank transaction narration. Supports all major Indian bank narration formats: - UPI: UPI/merchant_handle/purpose/BANK/ref/txn_id (ICICI, HDFC, Axis) - IMPS: IMPS/merchant_handle/... or IMPS-merchant_handle-... - NEFT: NEFT/merchant_handle/... or NEFT CR/merchant_name/... - RTGS: RTGS/merchant_handle/... or RTGS-merchant_handle-... - NACH: NACH/merchant_handle/... or NACH-merchant_handle-... - Generic: any string containing @vpa_handle pattern """ if not description: return None desc = description.strip() # Format: UPI/handle/... (ICICI, HDFC, Axis, etc.) if desc.upper().startswith('UPI/'): parts = desc.split('/') if len(parts) >= 2 and parts[1].strip(): return parts[1].strip().lower()[:100] # Format: GENERIC-UPI/handle/... (SBI) if 'UPI/' in desc.upper(): idx = desc.upper().index('UPI/') parts = desc[idx:].split('/') if len(parts) >= 2 and parts[1].strip(): return parts[1].strip().lower()[:100] # Generic stop-words that indicate the narration segment is NOT a merchant handle _NARRATION_STOP_WORDS = frozenset({ "transfer", "to", "from", "cr", "dr", "credit", "debit", "payment", "refund", "reversal", "charges", "fee", "salary", "interest", "dividend", "rent", "emi", "loan", "tax", "tds", "cash", "deposit", "withdrawal", }) # Format: IMPS/handle/... or IMPS-handle-... if desc.upper().startswith('IMPS'): parts = desc.split('/') if len(parts) >= 2 and parts[1].strip(): candidate = parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # IMPS-merchant-bank format dash_parts = desc.split('-') if len(dash_parts) >= 2 and dash_parts[1].strip(): candidate = dash_parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # Format: NEFT/handle/... or NEFT CR/handle/... or NEFT DR/handle/... if desc.upper().startswith('NEFT'): parts = desc.split('/') # Skip CR/DR suffix in first segment start_idx = 1 if len(parts) >= 2 and parts[0].strip().upper() in ('NEFT CR', 'NEFT DR'): start_idx = 1 if len(parts) > start_idx and parts[start_idx].strip(): candidate = parts[start_idx].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # NEFT-merchant-bank format dash_parts = desc.split('-') if len(dash_parts) >= 2 and dash_parts[1].strip(): candidate = dash_parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # Format: RTGS/handle/... or RTGS-handle-... if desc.upper().startswith('RTGS'): parts = desc.split('/') if len(parts) >= 2 and parts[1].strip(): candidate = parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate dash_parts = desc.split('-') if len(dash_parts) >= 2 and dash_parts[1].strip(): candidate = dash_parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # Format: NACH/handle/... or NACH-handle-... if desc.upper().startswith('NACH'): parts = desc.split('/') if len(parts) >= 2 and parts[1].strip(): candidate = parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate dash_parts = desc.split('-') if len(dash_parts) >= 2 and dash_parts[1].strip(): candidate = dash_parts[1].strip().lower()[:100] if candidate not in _NARRATION_STOP_WORDS: return candidate # Format: handle@vpa (direct UPI ID in description) m = re.search(r'([a-zA-Z0-9_.\-]{2,40}@[a-zA-Z]{2,20})', desc) if m: handle = m.group(1).lower() # Skip personal-looking handles (common names) personal_patterns = ['ybl', 'oksbi', 'okhdfc', 'okaxis', 'okicici', 'paytm', 'ibh', 'ybl', 'apl', 'axl', 'sbi', 'hdfcbank', 'icici', 'kotak'] vpa = handle.split('@')[1] if '@' in handle else '' if vpa in personal_patterns: return handle # Still return it — merchant DB can classify it as personal_transfer return handle # Format: UPI-DEBIT/handle/... or DEBIT-UPI/handle/... if 'UPI' in desc.upper(): parts = desc.split('/') for i, part in enumerate(parts): if part.strip().upper().startswith('UPI') and i + 1 < len(parts): handle = parts[i + 1].strip() if handle: return handle.lower()[:100] return None # Heuristic merchant name extraction from UPI handle def extract_display_name(upi_handle: str) -> str: """Extract a human-readable display name from a UPI handle.""" # Take the part before @ name = upi_handle.split('@')[0] if '@' in upi_handle else upi_handle # Remove common prefixes/suffixes name = re.sub(r'^(pay|p2p|p2m|merchant|txn|trn|order|bill)', '', name, flags=re.IGNORECASE) # Split on dots, hyphens, underscores and take meaningful parts parts = re.split(r'[.\-_\s]+', name) # Filter out short/empty parts and common noise meaningful = [p for p in parts if len(p) >= 2 and p.lower() not in ('upi', 'com', 'in', 'ltd')] if not meaningful: return name[:40].title() return ' '.join(meaningful[:3]).title()[:40] # Heuristic category classification based on UPI handle keywords MERCHANT_ALIASES = { # Handle pattern → (display_name, category, is_income, confidence) 'apple': ('Apple', 'entertainment', False, 0.85), 'appleservices': ('Apple Services', 'entertainment', False, 0.85), 'amznlpa': ('Amazon', 'shopping', False, 0.85), 'amazon': ('Amazon', 'shopping', False, 0.85), 'discovery': ('Discovery+', 'entertainment', False, 0.85), 'simpl': ('Simpl', 'credit_card', False, 0.85), 'setu.simpl': ('Simpl', 'credit_card', False, 0.85), 'dlf': ('DLF', 'bills', False, 0.75), 'ambience': ('Ambience Mall', 'shopping', False, 0.75), 'bistro': ('Bistro', 'food', False, 0.80), 'bundl': ('Swiggy', 'food', False, 0.90), 'eternal': ('Zomato', 'food', False, 0.90), 'zepto': ('Zepto', 'grocery', False, 0.90), 'blinkit': ('Blinkit', 'grocery', False, 0.90), 'groww': ('Groww', 'investment', False, 0.85), 'indmoney': ('IndMoney', 'investment', False, 0.85), 'zerodha': ('Zerodha', 'trading_deposit', False, 0.85), 'paytmqr': ('PayTM QR', 'bills', False, 0.70), 'qutab': ('Qutab Plaza', 'bills', False, 0.70), 'hsquare': ('H Square', 'bills', False, 0.70), 'rumaani': ('Rumaani', 'food', False, 0.70), 'laxman': ('Laxman Cafe', 'food', False, 0.70), 'vinod': ('Vinod Mandi', 'grocery', False, 0.70), 'idealprepa': ('Ideal Prep', 'education', False, 0.70), } CURATED_TRANSACTION_MARKERS = { 'gpaytoll@icici': ('Google Pay FASTag', 'travel', False, 0.98), 'blusmartmobilit': ('BluSmart', 'travel', False, 0.98), '1mg.payu@axisba': ('Tata 1mg', 'medical', False, 0.98), 'artemis ho': ('Artemis Hospital', 'medical', False, 0.98), 'artemishospita': ('Artemis Hospital', 'medical', False, 0.98), 'the chemis': ('The Chemist', 'medical', False, 0.95), 'the chemist': ('The Chemist', 'medical', False, 0.95), 'zomatoindia@ic': ('Zomato', 'food', False, 0.98), 'mgf mall m': ('MGF Mall Parking', 'bills', False, 0.95), 'med point': ('Med Point', 'medical', False, 0.95), } HANDLE_CATEGORY_MAP = { # Food delivery 'zomato': ('Zomato', 'food', False, 0.95), 'swiggy': ('Swiggy', 'food', False, 0.95), 'blinkit': ('Blinkit', 'grocery', False, 0.95), 'zepto': ('Zepto', 'grocery', False, 0.95), 'bigbasket': ('BigBasket', 'grocery', False, 0.95), 'dominos': ('Dominos', 'food', False, 0.92), 'pizzahut': ('Pizza Hut', 'food', False, 0.92), 'kfc': ('KFC', 'food', False, 0.90), 'mcdonald': ("McDonald's", 'food', False, 0.92), 'eatfit': ('EatFit', 'food', False, 0.85), 'box8': ('Box8', 'food', False, 0.85), # Shopping 'amazon': ('Amazon', 'shopping', False, 0.90), 'flipkart': ('Flipkart', 'shopping', False, 0.90), 'myntra': ('Myntra', 'shopping', False, 0.90), 'ajio': ('AJIO', 'shopping', False, 0.88), 'meesho': ('Meesho', 'shopping', False, 0.85), 'nykaa': ('Nykaa', 'shopping', False, 0.88), 'tatacliq': ('Tata CLiQ', 'shopping', False, 0.85), 'jiomart': ('JioMart', 'grocery', False, 0.88), 'bigbazaar': ('Big Bazaar', 'grocery', False, 0.82), # Travel 'uber': ('Uber', 'travel', False, 0.95), 'ola': ('Ola', 'travel', False, 0.95), 'blusmart': ('BluSmart', 'travel', False, 0.92), 'rapido': ('Rapido', 'travel', False, 0.92), 'irctc': ('IRCTC', 'travel', False, 0.95), 'makemytrip': ('MakeMyTrip', 'travel', False, 0.90), 'redbus': ('RedBus', 'travel', False, 0.90), 'goibibo': ('Goibibo', 'travel', False, 0.88), 'indigo': ('Indigo Airlines', 'travel', False, 0.92), 'airindia': ('Air India', 'travel', False, 0.90), # Entertainment 'netflix': ('Netflix', 'entertainment', False, 0.95), 'spotify': ('Spotify', 'entertainment', False, 0.95), 'hotstar': ('Disney+ Hotstar', 'entertainment', False, 0.92), 'prime': ('Amazon Prime', 'entertainment', False, 0.90), 'youtube': ('YouTube', 'entertainment', False, 0.95), 'playstore': ('Google Play Store', 'entertainment', False, 0.92), 'sonyliv': ('SonyLIV', 'entertainment', False, 0.88), 'jiosaavn': ('JioSaavn', 'entertainment', False, 0.85), # Bills & utilities 'gpay-utility': ('Google Pay Utility', 'bills', False, 0.80), 'mygate': ('MyGate', 'bills', False, 0.90), 'paytm-mygate': ('MyGate Society', 'bills', False, 0.90), 'electricity': ('Electricity Bill', 'bills', False, 0.82), 'water': ('Water Bill', 'bills', False, 0.80), 'gas': ('Gas Bill', 'bills', False, 0.80), 'broadband': ('Broadband Bill', 'bills', False, 0.82), 'airtel': ('Airtel', 'bills', False, 0.85), 'jio': ('Jio', 'bills', False, 0.82), 'vodafone': ('Vodafone Idea', 'bills', False, 0.80), 'bsnl': ('BSNL', 'bills', False, 0.80), # Insurance 'nivabupa': ('Niva Bupa Insurance', 'insurance', False, 0.92), 'hdfclife': ('HDFC Life', 'insurance', False, 0.90), 'iciciprulife': ('ICICI Prudential Life', 'insurance', False, 0.90), 'lic': ('LIC', 'insurance', False, 0.88), 'starhealth': ('Star Health', 'insurance', False, 0.88), # Trading / investments 'zerodha': ('Zerodha', 'trading_deposit', False, 0.98), 'groww': ('Groww', 'trading_deposit', False, 0.92), 'indmoney': ('INDmoney', 'investment', False, 0.90), 'upstox': ('Upstox', 'trading_deposit', False, 0.90), 'angelone': ('Angel One', 'trading_deposit', False, 0.90), '5paisa': ('5paisa', 'trading_deposit', False, 0.85), # Credit card payments via CRED — check before food/shopping (CRED intermediates for many merchants) 'cred.club': ('CRED', 'credit_card', False, 0.95), 'cred': ('CRED', 'credit_card', False, 0.95), 'paytm-jiomobili': ('CRED Bill Pay', 'bills', False, 0.82), 'payzomato@hdfcb': ('CRED Bill Pay', 'bills', False, 0.75), 'paytm-credit': ('Paytm Credit Card', 'credit_card', False, 0.88), # Medical 'pharmeasy': ('PharmEasy', 'medical', False, 0.90), 'tata1mg': ('Tata 1mg', 'medical', False, 0.90), '1mg': ('Tata 1mg', 'medical', False, 0.90), 'apollo': ('Apollo Pharmacy', 'medical', False, 0.82), 'netmeds': ('Netmeds', 'medical', False, 0.85), 'artemis': ('Artemis Hospital', 'medical', False, 0.88), # Education 'udemy': ('Udemy', 'education', False, 0.92), 'coursera': ('Coursera', 'education', False, 0.92), 'unacademy': ('Unacademy', 'education', False, 0.90), 'byjus': ("Byju's", 'education', False, 0.88), # Personal transfers (VPA patterns indicating P2P) 'ybl': ('UPI Transfer', 'personal_transfer', False, 0.40), 'oksbi': ('UPI Transfer', 'personal_transfer', False, 0.40), 'okhdfc': ('UPI Transfer', 'personal_transfer', False, 0.40), 'okaxis': ('UPI Transfer', 'personal_transfer', False, 0.40), 'okicici': ('UPI Transfer', 'personal_transfer', False, 0.40), 'apl': ('UPI Transfer', 'personal_transfer', False, 0.40), # --- GitHub-augmented: high-signal UPI handles from training data --- 'cred.club': ('CRED', 'credit_card', False, 0.95), 'payzomato': ('Zomato Pay (via CRED)', 'bills', False, 0.85), 'setu.simpl': ('Simpl', 'credit_card', False, 0.90), 'airindia.bdpg': ('Air India', 'travel', False, 0.90), 'paytmqr': ('Paytm Merchant', 'bills', False, 0.70), # --- Training-data misclassification fixes --- 'grofersindia': ('Blinkit (Grofers)', 'grocery', False, 0.85), 'flightsmojoin': ('Flight Booking', 'travel', False, 0.80), 'khargymkhana': ('Khar Gymkhana', 'health_fitness', False, 0.85), 'getsimpl': ('Simpl', 'credit_card', False, 0.90), # --- Cash withdrawal --- 'atm': ('ATM Withdrawal', 'cash_withdrawal', False, 0.85), } PERSONAL_TRANSFER_MARKERS = ( 'p2p', 'personal transfer', 'send money', ) # Generic category words belong to transaction-purpose inference, not merchant identity. GENERIC_NARRATION_KEYWORDS = { 'electricity', 'water', 'gas', 'broadband', 'jio', 'lic', 'atm', 'prime', } # Conservative purpose/category evidence from the complete bank narration. # These rules intentionally exclude generic words such as "payment" and "purchase". NARRATION_CATEGORY_RULES = ( ('credit_card', 'Credit Card Payment', 0.86, ( 'credit card bill', 'card bill payment', 'credit card payment', )), ('tax_payment', 'Tax Payment', 0.86, ( 'income tax', 'advance tax', 'tax challan', 'tax payment', )), ('insurance', 'Insurance Premium', 0.84, ( 'insurance premium', 'policy premium', )), ('medical', 'Medical', 0.80, ( 'pharmacy', 'hospital', 'medical store', 'clinic payment', )), ('education', 'Education', 0.80, ( 'school fee', 'college fee', 'tuition fee', 'course fee', )), ('trading_deposit', 'Trading Deposit', 0.82, ( 'trading account', 'broker deposit', )), ('investment', 'Investment', 0.82, ( 'mutual fund', 'sip investment', 'investment contribution', )), ('grocery', 'Grocery', 0.78, ( 'grocery', 'supermarket', 'kirana', 'provision store', )), ('food', 'Food', 0.76, ( 'restaurant', 'food order', 'cafe payment', 'meal payment', )), ('travel', 'Travel', 0.78, ( 'flight booking', 'hotel booking', 'cab ride', 'railway ticket', 'travel booking', )), ('entertainment', 'Entertainment', 0.76, ( 'movie ticket', 'cinema', 'streaming subscription', )), ('bills', 'Utility Bill', 0.78, ( 'electricity bill', 'water bill', 'gas bill', 'mobile recharge', 'broadband bill', 'utility bill', )), ('shopping', 'Shopping', 0.72, ( 'retail purchase', 'shopping order', 'apparel', 'electronics purchase', )), ('staff_salary', 'Staff Salary', 0.82, ( 'staff salary', 'maid salary', 'driver salary', )), ('donation', 'Donation', 0.78, ('donation', 'charity contribution')), ('cash_withdrawal', 'Cash Withdrawal', 0.85, ( 'cash withdrawal', 'upi atm withdrawal', )), ) def _normalize_evidence(value: str) -> str: """Normalize narration text for conservative token/phrase matching.""" return ' '.join(re.sub(r'[^a-z0-9]+', ' ', value.lower()).split()) def _contains_evidence(value: str, phrase: str) -> bool: """Match a normalized token or phrase without accidental substrings.""" normalized_value = f" {_normalize_evidence(value)} " normalized_phrase = _normalize_evidence(phrase) return bool(normalized_phrase) and f" {normalized_phrase} " in normalized_value def _handle_contains_keyword(handle: str, keyword: str) -> bool: """Match exact token phrases or brand-prefixed handle tokens without infixes.""" if _contains_evidence(handle, keyword): return True compact_keyword = _normalize_evidence(keyword).replace(" ", "") if len(compact_keyword) <= 4: return False handle_tokens = re.findall(r"[a-z0-9]+", handle.lower()) return any(token.startswith(compact_keyword) for token in handle_tokens) def get_curated_transaction_override( upi_handle: str, sample_description: str = "", ) -> Optional[dict]: """Return only exact transaction markers that may outrank learned cache rows.""" handle_lower = (upi_handle or "").lower().strip() description_lower = (sample_description or "").lower() for marker, (display, category, is_income, confidence) in CURATED_TRANSACTION_MARKERS.items(): marker_pattern = rf"(? Optional[dict]: """Return curated markers and handle aliases for heuristic classification.""" curated = get_curated_transaction_override(upi_handle, sample_description) if curated: return curated handle_lower = (upi_handle or "").lower().strip() for alias_key, (display, category, is_income, confidence) in MERCHANT_ALIASES.items(): if _handle_contains_keyword(handle_lower, alias_key): return { "display_name": display, "category": category, "is_income": is_income, "confidence": confidence, "rationale": f"Merchant alias: {display}", } return None def classify_upi_merchant( upi_handle: str, sample_description: str, *, learn: bool = True, ) -> dict: """Infer a UPI category, optionally learning stable handle evidence.""" handle_lower = (upi_handle or '').lower().strip() description = sample_description or '' curated = get_curated_merchant_override(upi_handle, description) if curated: return curated # Exact handle identity always outranks incidental merchant text in narration. sorted_map = sorted(HANDLE_CATEGORY_MAP.items(), key=lambda item: len(item[0]), reverse=True) merchant_match = next( ( (keyword, merchant) for keyword, merchant in sorted_map if merchant[1] != 'personal_transfer' and _handle_contains_keyword(handle_lower, keyword) ), None, ) # Only high-confidence, sufficiently specific merchant names may match narration. if merchant_match is None: merchant_match = next( ( (keyword, merchant) for keyword, merchant in sorted_map if merchant[1] != 'personal_transfer' and merchant[3] >= 0.80 and keyword not in GENERIC_NARRATION_KEYWORDS and len(_normalize_evidence(keyword).replace(' ', '')) >= 4 and _contains_evidence(description, keyword) ), None, ) if merchant_match is not None: keyword, (display, category, is_income, confidence) = merchant_match if category == 'credit_card' and 'cred' in keyword: for part in description.split('/'): part = part.strip() if any(bank in part.upper() for bank in [ 'AXIS BANK', 'HDFC BANK', 'ICICI BANK', 'SBI', 'YES BANK', 'KOTAK', 'IDFC', 'INDUSIND', 'AMERICAN EXPRESS', 'STANDARD CHARTED', 'STANDARD CHARTERED', 'RBL', 'FEDERAL', 'BANDHAN', 'YES BANK LIMITE', ]): display = f'CRED — {part.title()}' break # Specific known-merchant evidence is stable enough to learn for this handle. if handle_lower and learn: try: store_merchant( upi_handle, display, category, is_income=is_income, confidence=confidence, sample_desc=description[:200], ) except Exception: pass return { 'display_name': display, 'category': category, 'is_income': is_income, 'confidence': confidence, 'rationale': f'Known UPI merchant evidence: {display}', } display = extract_display_name(handle_lower) or 'Unknown UPI counterparty' # Purpose/category evidence is transaction-specific, so do not cache it by handle. for category, generic_display, confidence, phrases in NARRATION_CATEGORY_RULES: matched_phrase = next( (phrase for phrase in phrases if _contains_evidence(description, phrase)), None, ) if matched_phrase: return { 'display_name': display if display != 'Unknown UPI counterparty' else generic_display, 'category': category, 'is_income': False, 'confidence': confidence, 'rationale': f'UPI narration evidence: {matched_phrase}', } local_part = handle_lower.split('@', 1)[0] compact_local = re.sub(r'[^a-z0-9]', '', local_part) mostly_numeric = bool(compact_local) and ( compact_local.isdigit() or sum(character.isdigit() for character in compact_local) / len(compact_local) >= 0.8 ) explicit_personal = any( _contains_evidence(description, marker) for marker in PERSONAL_TRANSFER_MARKERS ) # Indian person-name P2P detection local_part_fallback = handle_lower.split('@', 1)[0] if handle_lower else '' # Remove non-alpha chars to evaluate the name alpha_only = re.sub(r'[^a-z]', '', local_part_fallback) # Skip masked handles (xxxxxxxxxx), repeated-char handles, and handles # where the local part is mostly one repeated character — these are # privacy-masked VPAs, not person names unique_chars = set(alpha_only) is_masked = len(unique_chars) <= 2 # e.g. "xxxxxxxxxx" → {'x'} → masked # If handle local part is 5+ alphabetic chars, has no merchant keywords, # no digits, no known brand indicators, and doesn't match any narration # category → classify as personal_transfer at 0.40 confidence if (len(alpha_only) >= 5 and not is_masked # exclude masked/repeated-char handles and not any(kw in alpha_only for kw in ( 'paytm', 'phonepe', 'gpay', 'amazon', 'flipkart', 'zomato', 'swiggy', 'blinkit', 'zepto', 'cred', 'bill', 'pay', 'tax', 'loan', 'emi', 'insur', 'med', 'hospital', 'pharma', 'food', 'mart', 'store', 'shop', 'bazar', 'mall', 'petrol', 'gas', 'electric', 'water', 'broadband', 'recharge', 'netflix', 'spotify', 'prime', 'hotstar', 'disney', 'apple', 'google', 'flight', 'air', 'irctc', 'mmt', 'makemy', 'yatra', 'goibibo', 'cleartrip', 'uber', 'ola', 'rapido', 'rent', 'pg', 'hostel', )) and not mostly_numeric # already handled above and not explicit_personal # already handled above and not merchant_match # no merchant evidence found and not any(_contains_evidence(description, phrase) for category, _, _, phrases in NARRATION_CATEGORY_RULES for phrase in phrases) ): return { 'display_name': 'UPI Transfer', 'category': 'personal_transfer', 'is_income': False, 'confidence': 0.40, 'rationale': 'Personal UPI transfer — no merchant evidence in handle or narration', } if mostly_numeric or explicit_personal: return { 'display_name': 'UPI Transfer', 'category': 'personal_transfer', 'is_income': False, 'confidence': 0.55 if mostly_numeric else 0.60, 'rationale': 'Strong personal-transfer evidence in UPI transaction', } return { 'display_name': display, 'category': 'unclassified', 'is_income': False, 'confidence': 0.35, 'rationale': 'No reliable merchant or purpose evidence in UPI transaction', } def store_merchant(upi_handle: str, display_name: str, category: str, is_income: bool = False, confidence: float = 0.85, sample_desc: str = '') -> bool: """Store a classified merchant in the database.""" conn = sqlite3.connect(str(DB_PATH)) try: conn.execute( """INSERT OR REPLACE INTO merchants (upi_handle, display_name, category, is_income, confidence, sample_desc) VALUES (?, ?, ?, ?, ?, ?)""", (upi_handle.lower(), display_name, category, 1 if is_income else 0, confidence, sample_desc[:200]) ) conn.commit() return True except Exception as e: print(f"Error storing merchant {upi_handle}: {e}") return False finally: conn.close() def batch_store(merchants: list[dict]) -> int: """Store multiple merchants at once. Each dict: {upi_handle, display_name, category, is_income, confidence, sample_desc}""" conn = sqlite3.connect(str(DB_PATH)) count = 0 for m in merchants: try: conn.execute( """INSERT OR REPLACE INTO merchants (upi_handle, display_name, category, is_income, confidence, sample_desc) VALUES (?, ?, ?, ?, ?, ?)""", (m['upi_handle'].lower(), m['display_name'], m['category'], 1 if m.get('is_income') else 0, m.get('confidence', 0.85), m.get('sample_desc', '')[:200]) ) count += 1 except Exception: pass conn.commit() conn.close() return count def get_db_stats() -> dict: """Get statistics about the merchant database.""" conn = sqlite3.connect(str(DB_PATH)) total = conn.execute("SELECT COUNT(*) FROM merchants").fetchone()[0] by_cat = conn.execute( "SELECT category, COUNT(*) as cnt FROM merchants GROUP BY category ORDER BY cnt DESC" ).fetchall() conn.close() return { 'total_merchants': total, 'categories': {cat: cnt for cat, cnt in by_cat} } # ─── Seed Data: Known merchants from regex patterns ─── SEED_MERCHANTS = [ # Trading / investments {'upi_handle': 'zerodhabroking@', 'display_name': 'Zerodha', 'category': 'trading_deposit', 'is_income': False, 'confidence': 0.98}, {'upi_handle': 'indmoney@', 'display_name': 'INDmoney', 'category': 'investment', 'is_income': False, 'confidence': 0.90}, # Food delivery {'upi_handle': 'zomato-order@pt', 'display_name': 'Zomato', 'category': 'food', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'swiggy@', 'display_name': 'Swiggy', 'category': 'food', 'is_income': False, 'confidence': 0.95}, # Shopping {'upi_handle': 'amazon-pod@rap', 'display_name': 'Amazon', 'category': 'shopping', 'is_income': False, 'confidence': 0.90}, {'upi_handle': 'amazonsellerser', 'display_name': 'Amazon Seller Services', 'category': 'shopping', 'is_income': False, 'confidence': 0.85}, {'upi_handle': 'flipkart@', 'display_name': 'Flipkart', 'category': 'shopping', 'is_income': False, 'confidence': 0.90}, # Bills & utilities {'upi_handle': 'gpay-utility@ok', 'display_name': 'Google Pay Utility', 'category': 'bills', 'is_income': False, 'confidence': 0.80}, {'upi_handle': 'youtube@axisba', 'display_name': 'YouTube Premium', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'playstore@axis', 'display_name': 'Google Play Store', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'netflix@', 'display_name': 'Netflix', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95}, # Insurance (known providers) {'upi_handle': 'nivabupa@', 'display_name': 'Niva Bupa Insurance', 'category': 'insurance', 'is_income': False, 'confidence': 0.92}, # Society / maintenance {'upi_handle': 'paytm-mygate@pt', 'display_name': 'MyGate Society', 'category': 'bills', 'is_income': False, 'confidence': 0.90}, {'upi_handle': 'mygate.razorpa', 'display_name': 'MyGate', 'category': 'bills', 'is_income': False, 'confidence': 0.90}, # Travel {'upi_handle': 'uber@', 'display_name': 'Uber', 'category': 'travel', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'ola@', 'display_name': 'Ola', 'category': 'travel', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'irctc@', 'display_name': 'IRCTC', 'category': 'travel', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'airindiaexpress', 'display_name': 'Air India Express', 'category': 'travel', 'is_income': False, 'confidence': 0.90}, # Credit card payments {'upi_handle': 'cred@', 'display_name': 'CRED', 'category': 'credit_card', 'is_income': False, 'confidence': 0.95}, # Grocery {'upi_handle': 'blinkit@', 'display_name': 'Blinkit', 'category': 'grocery', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'zepto@', 'display_name': 'Zepto', 'category': 'grocery', 'is_income': False, 'confidence': 0.95}, {'upi_handle': 'bigbasket@', 'display_name': 'BigBasket', 'category': 'grocery', 'is_income': False, 'confidence': 0.95}, ] def seed_database(): """Initialize the merchant database with known merchants.""" count = batch_store(SEED_MERCHANTS) print(f"Seeded {count} known merchants") return count