SahilGoel commited on
Commit
f501431
·
verified ·
1 Parent(s): 2725543

Upload code/merchant_classifier.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/merchant_classifier.py +795 -0
code/merchant_classifier.py ADDED
@@ -0,0 +1,795 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Merchant Classifier — LLM-powered UPI merchant identification with caching.
3
+
4
+ Flow:
5
+ 1. Extract UPI handle from transaction description
6
+ 2. Look up in SQLite merchant DB → return if found
7
+ 3. If not found, call LLM to classify → store in DB → return
8
+ 4. DB acts as persistent cache — LLM called only once per new merchant
9
+
10
+ DB tables:
11
+ - merchants: upi_handle → display_name, category, is_income, confidence
12
+ - merchant_aliases: canonical_name → upi_handle (for dedup)
13
+ """
14
+
15
+ import sqlite3
16
+ import re
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Optional, Tuple
20
+
21
+ DB_PATH = Path(__file__).parent.parent.parent / "data" / "merchants.db"
22
+ # Persist outside of rsync path so deploys don't wipe it
23
+
24
+
25
+ def get_merchant(upi_handle: str) -> Optional[dict]:
26
+ """Look up a UPI handle in the merchant database."""
27
+ conn = None
28
+ try:
29
+ conn = sqlite3.connect(str(DB_PATH))
30
+ conn.row_factory = sqlite3.Row
31
+ row = conn.execute(
32
+ "SELECT * FROM merchants WHERE upi_handle = ?", (upi_handle,)
33
+ ).fetchone()
34
+ return dict(row) if row else None
35
+ except sqlite3.OperationalError:
36
+ return None
37
+ finally:
38
+ if conn is not None:
39
+ conn.close()
40
+
41
+
42
+ def normalize_description_key(desc: str) -> str:
43
+ """Normalize description for rule matching: uppercase, strip digits, collapse whitespace."""
44
+ if not desc:
45
+ return ""
46
+ text = str(desc).upper().strip()
47
+ text = re.sub(r'\d+', '', text)
48
+ text = re.sub(r'\s+', ' ', text).strip()
49
+ # Guard against very short keys that could match unrelated transactions
50
+ if len(text) < 5:
51
+ return ""
52
+ return text[:200]
53
+
54
+
55
+ def _ensure_description_rules_table(conn):
56
+ conn.execute(
57
+ """
58
+ CREATE TABLE IF NOT EXISTS description_rules (
59
+ description_key TEXT PRIMARY KEY,
60
+ category TEXT NOT NULL,
61
+ is_income INTEGER DEFAULT 0,
62
+ confidence REAL DEFAULT 0.90,
63
+ sample_desc TEXT,
64
+ created_at TEXT
65
+ )
66
+ """
67
+ )
68
+
69
+
70
+ def store_description_rule(desc: str, category: str, is_income: bool, confidence: float = 0.90) -> bool:
71
+ key = normalize_description_key(desc)
72
+ if not key:
73
+ return False
74
+ conn = sqlite3.connect(str(DB_PATH))
75
+ try:
76
+ _ensure_description_rules_table(conn)
77
+ import datetime
78
+ now_str = datetime.datetime.now(datetime.timezone.utc).isoformat()
79
+ conn.execute(
80
+ """INSERT OR REPLACE INTO description_rules
81
+ (description_key, category, is_income, confidence, sample_desc, created_at)
82
+ VALUES (?, ?, ?, ?, ?, ?)""",
83
+ (key, category, 1 if is_income else 0, confidence, desc[:200], now_str)
84
+ )
85
+ conn.commit()
86
+ return True
87
+ except Exception as e:
88
+ print(f"Error storing description rule {key}: {e}")
89
+ return False
90
+ finally:
91
+ conn.close()
92
+
93
+
94
+ def get_description_rule(desc: str) -> Optional[dict]:
95
+ key = normalize_description_key(desc)
96
+ if not key:
97
+ return None
98
+ conn = None
99
+ try:
100
+ conn = sqlite3.connect(str(DB_PATH))
101
+ _ensure_description_rules_table(conn)
102
+ conn.row_factory = sqlite3.Row
103
+ row = conn.execute(
104
+ "SELECT * FROM description_rules WHERE description_key = ?", (key,)
105
+ ).fetchone()
106
+ return dict(row) if row else None
107
+ except Exception:
108
+ return None
109
+ finally:
110
+ if conn is not None:
111
+ conn.close()
112
+
113
+
114
+ def extract_upi_handle(description: str) -> Optional[str]:
115
+ """Extract the merchant/counterparty handle from an Indian bank transaction narration.
116
+
117
+ Supports all major Indian bank narration formats:
118
+ - UPI: UPI/merchant_handle/purpose/BANK/ref/txn_id (ICICI, HDFC, Axis)
119
+ - IMPS: IMPS/merchant_handle/... or IMPS-merchant_handle-...
120
+ - NEFT: NEFT/merchant_handle/... or NEFT CR/merchant_name/...
121
+ - RTGS: RTGS/merchant_handle/... or RTGS-merchant_handle-...
122
+ - NACH: NACH/merchant_handle/... or NACH-merchant_handle-...
123
+ - Generic: any string containing @vpa_handle pattern
124
+ """
125
+ if not description:
126
+ return None
127
+ desc = description.strip()
128
+
129
+ # Format: UPI/handle/... (ICICI, HDFC, Axis, etc.)
130
+ if desc.upper().startswith('UPI/'):
131
+ parts = desc.split('/')
132
+ if len(parts) >= 2 and parts[1].strip():
133
+ return parts[1].strip().lower()[:100]
134
+
135
+ # Format: GENERIC-UPI/handle/... (SBI)
136
+ if 'UPI/' in desc.upper():
137
+ idx = desc.upper().index('UPI/')
138
+ parts = desc[idx:].split('/')
139
+ if len(parts) >= 2 and parts[1].strip():
140
+ return parts[1].strip().lower()[:100]
141
+
142
+ # Generic stop-words that indicate the narration segment is NOT a merchant handle
143
+ _NARRATION_STOP_WORDS = frozenset({
144
+ "transfer", "to", "from", "cr", "dr", "credit", "debit",
145
+ "payment", "refund", "reversal", "charges", "fee",
146
+ "salary", "interest", "dividend", "rent", "emi", "loan",
147
+ "tax", "tds", "cash", "deposit", "withdrawal",
148
+ })
149
+
150
+ # Format: IMPS/handle/... or IMPS-handle-...
151
+ if desc.upper().startswith('IMPS'):
152
+ parts = desc.split('/')
153
+ if len(parts) >= 2 and parts[1].strip():
154
+ candidate = parts[1].strip().lower()[:100]
155
+ if candidate not in _NARRATION_STOP_WORDS:
156
+ return candidate
157
+ # IMPS-merchant-bank format
158
+ dash_parts = desc.split('-')
159
+ if len(dash_parts) >= 2 and dash_parts[1].strip():
160
+ candidate = dash_parts[1].strip().lower()[:100]
161
+ if candidate not in _NARRATION_STOP_WORDS:
162
+ return candidate
163
+
164
+ # Format: NEFT/handle/... or NEFT CR/handle/... or NEFT DR/handle/...
165
+ if desc.upper().startswith('NEFT'):
166
+ parts = desc.split('/')
167
+ # Skip CR/DR suffix in first segment
168
+ start_idx = 1
169
+ if len(parts) >= 2 and parts[0].strip().upper() in ('NEFT CR', 'NEFT DR'):
170
+ start_idx = 1
171
+ if len(parts) > start_idx and parts[start_idx].strip():
172
+ candidate = parts[start_idx].strip().lower()[:100]
173
+ if candidate not in _NARRATION_STOP_WORDS:
174
+ return candidate
175
+ # NEFT-merchant-bank format
176
+ dash_parts = desc.split('-')
177
+ if len(dash_parts) >= 2 and dash_parts[1].strip():
178
+ candidate = dash_parts[1].strip().lower()[:100]
179
+ if candidate not in _NARRATION_STOP_WORDS:
180
+ return candidate
181
+
182
+ # Format: RTGS/handle/... or RTGS-handle-...
183
+ if desc.upper().startswith('RTGS'):
184
+ parts = desc.split('/')
185
+ if len(parts) >= 2 and parts[1].strip():
186
+ candidate = parts[1].strip().lower()[:100]
187
+ if candidate not in _NARRATION_STOP_WORDS:
188
+ return candidate
189
+ dash_parts = desc.split('-')
190
+ if len(dash_parts) >= 2 and dash_parts[1].strip():
191
+ candidate = dash_parts[1].strip().lower()[:100]
192
+ if candidate not in _NARRATION_STOP_WORDS:
193
+ return candidate
194
+
195
+ # Format: NACH/handle/... or NACH-handle-...
196
+ if desc.upper().startswith('NACH'):
197
+ parts = desc.split('/')
198
+ if len(parts) >= 2 and parts[1].strip():
199
+ candidate = parts[1].strip().lower()[:100]
200
+ if candidate not in _NARRATION_STOP_WORDS:
201
+ return candidate
202
+ dash_parts = desc.split('-')
203
+ if len(dash_parts) >= 2 and dash_parts[1].strip():
204
+ candidate = dash_parts[1].strip().lower()[:100]
205
+ if candidate not in _NARRATION_STOP_WORDS:
206
+ return candidate
207
+
208
+ # Format: handle@vpa (direct UPI ID in description)
209
+ m = re.search(r'([a-zA-Z0-9_.\-]{2,40}@[a-zA-Z]{2,20})', desc)
210
+ if m:
211
+ handle = m.group(1).lower()
212
+ # Skip personal-looking handles (common names)
213
+ personal_patterns = ['ybl', 'oksbi', 'okhdfc', 'okaxis', 'okicici', 'paytm', 'ibh',
214
+ 'ybl', 'apl', 'axl', 'sbi', 'hdfcbank', 'icici', 'kotak']
215
+ vpa = handle.split('@')[1] if '@' in handle else ''
216
+ if vpa in personal_patterns:
217
+ return handle # Still return it — merchant DB can classify it as personal_transfer
218
+ return handle
219
+
220
+ # Format: UPI-DEBIT/handle/... or DEBIT-UPI/handle/...
221
+ if 'UPI' in desc.upper():
222
+ parts = desc.split('/')
223
+ for i, part in enumerate(parts):
224
+ if part.strip().upper().startswith('UPI') and i + 1 < len(parts):
225
+ handle = parts[i + 1].strip()
226
+ if handle:
227
+ return handle.lower()[:100]
228
+
229
+ return None
230
+
231
+
232
+ # Heuristic merchant name extraction from UPI handle
233
+ def extract_display_name(upi_handle: str) -> str:
234
+ """Extract a human-readable display name from a UPI handle."""
235
+ # Take the part before @
236
+ name = upi_handle.split('@')[0] if '@' in upi_handle else upi_handle
237
+ # Remove common prefixes/suffixes
238
+ name = re.sub(r'^(pay|p2p|p2m|merchant|txn|trn|order|bill)', '', name, flags=re.IGNORECASE)
239
+ # Split on dots, hyphens, underscores and take meaningful parts
240
+ parts = re.split(r'[.\-_\s]+', name)
241
+ # Filter out short/empty parts and common noise
242
+ meaningful = [p for p in parts if len(p) >= 2 and p.lower() not in ('upi', 'com', 'in', 'ltd')]
243
+ if not meaningful:
244
+ return name[:40].title()
245
+ return ' '.join(meaningful[:3]).title()[:40]
246
+
247
+
248
+ # Heuristic category classification based on UPI handle keywords
249
+ MERCHANT_ALIASES = {
250
+ # Handle pattern → (display_name, category, is_income, confidence)
251
+ 'apple': ('Apple', 'entertainment', False, 0.85),
252
+ 'appleservices': ('Apple Services', 'entertainment', False, 0.85),
253
+ 'amznlpa': ('Amazon', 'shopping', False, 0.85),
254
+ 'amazon': ('Amazon', 'shopping', False, 0.85),
255
+ 'discovery': ('Discovery+', 'entertainment', False, 0.85),
256
+ 'simpl': ('Simpl', 'credit_card', False, 0.85),
257
+ 'setu.simpl': ('Simpl', 'credit_card', False, 0.85),
258
+ 'dlf': ('DLF', 'bills', False, 0.75),
259
+ 'ambience': ('Ambience Mall', 'shopping', False, 0.75),
260
+ 'bistro': ('Bistro', 'food', False, 0.80),
261
+ 'bundl': ('Swiggy', 'food', False, 0.90),
262
+ 'eternal': ('Zomato', 'food', False, 0.90),
263
+ 'zepto': ('Zepto', 'grocery', False, 0.90),
264
+ 'blinkit': ('Blinkit', 'grocery', False, 0.90),
265
+ 'groww': ('Groww', 'investment', False, 0.85),
266
+ 'indmoney': ('IndMoney', 'investment', False, 0.85),
267
+ 'zerodha': ('Zerodha', 'trading_deposit', False, 0.85),
268
+ 'paytmqr': ('PayTM QR', 'bills', False, 0.70),
269
+ 'qutab': ('Qutab Plaza', 'bills', False, 0.70),
270
+ 'hsquare': ('H Square', 'bills', False, 0.70),
271
+ 'rumaani': ('Rumaani', 'food', False, 0.70),
272
+ 'laxman': ('Laxman Cafe', 'food', False, 0.70),
273
+ 'vinod': ('Vinod Mandi', 'grocery', False, 0.70),
274
+ 'idealprepa': ('Ideal Prep', 'education', False, 0.70),
275
+ }
276
+
277
+ CURATED_TRANSACTION_MARKERS = {
278
+ 'gpaytoll@icici': ('Google Pay FASTag', 'travel', False, 0.98),
279
+ 'blusmartmobilit': ('BluSmart', 'travel', False, 0.98),
280
+ '1mg.payu@axisba': ('Tata 1mg', 'medical', False, 0.98),
281
+ 'artemis ho': ('Artemis Hospital', 'medical', False, 0.98),
282
+ 'artemishospita': ('Artemis Hospital', 'medical', False, 0.98),
283
+ 'the chemis': ('The Chemist', 'medical', False, 0.95),
284
+ 'the chemist': ('The Chemist', 'medical', False, 0.95),
285
+ 'zomatoindia@ic': ('Zomato', 'food', False, 0.98),
286
+ 'mgf mall m': ('MGF Mall Parking', 'bills', False, 0.95),
287
+ 'med point': ('Med Point', 'medical', False, 0.95),
288
+ }
289
+
290
+ HANDLE_CATEGORY_MAP = {
291
+ # Food delivery
292
+ 'zomato': ('Zomato', 'food', False, 0.95),
293
+ 'swiggy': ('Swiggy', 'food', False, 0.95),
294
+ 'blinkit': ('Blinkit', 'grocery', False, 0.95),
295
+ 'zepto': ('Zepto', 'grocery', False, 0.95),
296
+ 'bigbasket': ('BigBasket', 'grocery', False, 0.95),
297
+ 'dominos': ('Dominos', 'food', False, 0.92),
298
+ 'pizzahut': ('Pizza Hut', 'food', False, 0.92),
299
+ 'kfc': ('KFC', 'food', False, 0.90),
300
+ 'mcdonald': ("McDonald's", 'food', False, 0.92),
301
+ 'eatfit': ('EatFit', 'food', False, 0.85),
302
+ 'box8': ('Box8', 'food', False, 0.85),
303
+ # Shopping
304
+ 'amazon': ('Amazon', 'shopping', False, 0.90),
305
+ 'flipkart': ('Flipkart', 'shopping', False, 0.90),
306
+ 'myntra': ('Myntra', 'shopping', False, 0.90),
307
+ 'ajio': ('AJIO', 'shopping', False, 0.88),
308
+ 'meesho': ('Meesho', 'shopping', False, 0.85),
309
+ 'nykaa': ('Nykaa', 'shopping', False, 0.88),
310
+ 'tatacliq': ('Tata CLiQ', 'shopping', False, 0.85),
311
+ 'jiomart': ('JioMart', 'grocery', False, 0.88),
312
+ 'bigbazaar': ('Big Bazaar', 'grocery', False, 0.82),
313
+ # Travel
314
+ 'uber': ('Uber', 'travel', False, 0.95),
315
+ 'ola': ('Ola', 'travel', False, 0.95),
316
+ 'blusmart': ('BluSmart', 'travel', False, 0.92),
317
+ 'rapido': ('Rapido', 'travel', False, 0.92),
318
+ 'irctc': ('IRCTC', 'travel', False, 0.95),
319
+ 'makemytrip': ('MakeMyTrip', 'travel', False, 0.90),
320
+ 'redbus': ('RedBus', 'travel', False, 0.90),
321
+ 'goibibo': ('Goibibo', 'travel', False, 0.88),
322
+ 'indigo': ('Indigo Airlines', 'travel', False, 0.92),
323
+ 'airindia': ('Air India', 'travel', False, 0.90),
324
+ # Entertainment
325
+ 'netflix': ('Netflix', 'entertainment', False, 0.95),
326
+ 'spotify': ('Spotify', 'entertainment', False, 0.95),
327
+ 'hotstar': ('Disney+ Hotstar', 'entertainment', False, 0.92),
328
+ 'prime': ('Amazon Prime', 'entertainment', False, 0.90),
329
+ 'youtube': ('YouTube', 'entertainment', False, 0.95),
330
+ 'playstore': ('Google Play Store', 'entertainment', False, 0.92),
331
+ 'sonyliv': ('SonyLIV', 'entertainment', False, 0.88),
332
+ 'jiosaavn': ('JioSaavn', 'entertainment', False, 0.85),
333
+ # Bills & utilities
334
+ 'gpay-utility': ('Google Pay Utility', 'bills', False, 0.80),
335
+ 'mygate': ('MyGate', 'bills', False, 0.90),
336
+ 'paytm-mygate': ('MyGate Society', 'bills', False, 0.90),
337
+ 'electricity': ('Electricity Bill', 'bills', False, 0.82),
338
+ 'water': ('Water Bill', 'bills', False, 0.80),
339
+ 'gas': ('Gas Bill', 'bills', False, 0.80),
340
+ 'broadband': ('Broadband Bill', 'bills', False, 0.82),
341
+ 'airtel': ('Airtel', 'bills', False, 0.85),
342
+ 'jio': ('Jio', 'bills', False, 0.82),
343
+ 'vodafone': ('Vodafone Idea', 'bills', False, 0.80),
344
+ 'bsnl': ('BSNL', 'bills', False, 0.80),
345
+ # Insurance
346
+ 'nivabupa': ('Niva Bupa Insurance', 'insurance', False, 0.92),
347
+ 'hdfclife': ('HDFC Life', 'insurance', False, 0.90),
348
+ 'iciciprulife': ('ICICI Prudential Life', 'insurance', False, 0.90),
349
+ 'lic': ('LIC', 'insurance', False, 0.88),
350
+ 'starhealth': ('Star Health', 'insurance', False, 0.88),
351
+ # Trading / investments
352
+ 'zerodha': ('Zerodha', 'trading_deposit', False, 0.98),
353
+ 'groww': ('Groww', 'trading_deposit', False, 0.92),
354
+ 'indmoney': ('INDmoney', 'investment', False, 0.90),
355
+ 'upstox': ('Upstox', 'trading_deposit', False, 0.90),
356
+ 'angelone': ('Angel One', 'trading_deposit', False, 0.90),
357
+ '5paisa': ('5paisa', 'trading_deposit', False, 0.85),
358
+ # Credit card payments via CRED — check before food/shopping (CRED intermediates for many merchants)
359
+ 'cred.club': ('CRED', 'credit_card', False, 0.95),
360
+ 'cred': ('CRED', 'credit_card', False, 0.95),
361
+ 'paytm-jiomobili': ('CRED Bill Pay', 'bills', False, 0.82),
362
+ 'payzomato@hdfcb': ('CRED Bill Pay', 'bills', False, 0.75),
363
+ 'paytm-credit': ('Paytm Credit Card', 'credit_card', False, 0.88),
364
+ # Medical
365
+ 'pharmeasy': ('PharmEasy', 'medical', False, 0.90),
366
+ 'tata1mg': ('Tata 1mg', 'medical', False, 0.90),
367
+ '1mg': ('Tata 1mg', 'medical', False, 0.90),
368
+ 'apollo': ('Apollo Pharmacy', 'medical', False, 0.82),
369
+ 'netmeds': ('Netmeds', 'medical', False, 0.85),
370
+ 'artemis': ('Artemis Hospital', 'medical', False, 0.88),
371
+ # Education
372
+ 'udemy': ('Udemy', 'education', False, 0.92),
373
+ 'coursera': ('Coursera', 'education', False, 0.92),
374
+ 'unacademy': ('Unacademy', 'education', False, 0.90),
375
+ 'byjus': ("Byju's", 'education', False, 0.88),
376
+ # Personal transfers (VPA patterns indicating P2P)
377
+ 'ybl': ('UPI Transfer', 'personal_transfer', False, 0.40),
378
+ 'oksbi': ('UPI Transfer', 'personal_transfer', False, 0.40),
379
+ 'okhdfc': ('UPI Transfer', 'personal_transfer', False, 0.40),
380
+ 'okaxis': ('UPI Transfer', 'personal_transfer', False, 0.40),
381
+ 'okicici': ('UPI Transfer', 'personal_transfer', False, 0.40),
382
+ 'apl': ('UPI Transfer', 'personal_transfer', False, 0.40),
383
+ # --- GitHub-augmented: high-signal UPI handles from training data ---
384
+ 'cred.club': ('CRED', 'credit_card', False, 0.95),
385
+ 'payzomato': ('Zomato Pay (via CRED)', 'bills', False, 0.85),
386
+ 'setu.simpl': ('Simpl', 'credit_card', False, 0.90),
387
+ 'airindia.bdpg': ('Air India', 'travel', False, 0.90),
388
+ 'paytmqr': ('Paytm Merchant', 'bills', False, 0.70),
389
+ # --- Training-data misclassification fixes ---
390
+ 'grofersindia': ('Blinkit (Grofers)', 'grocery', False, 0.85),
391
+ 'flightsmojoin': ('Flight Booking', 'travel', False, 0.80),
392
+ 'khargymkhana': ('Khar Gymkhana', 'health_fitness', False, 0.85),
393
+ 'getsimpl': ('Simpl', 'credit_card', False, 0.90),
394
+ # --- Cash withdrawal ---
395
+ 'atm': ('ATM Withdrawal', 'cash_withdrawal', False, 0.85),
396
+ }
397
+
398
+
399
+ PERSONAL_TRANSFER_MARKERS = (
400
+ 'p2p',
401
+ 'personal transfer',
402
+ 'send money',
403
+ )
404
+
405
+ # Generic category words belong to transaction-purpose inference, not merchant identity.
406
+ GENERIC_NARRATION_KEYWORDS = {
407
+ 'electricity', 'water', 'gas', 'broadband', 'jio', 'lic', 'atm', 'prime',
408
+ }
409
+
410
+ # Conservative purpose/category evidence from the complete bank narration.
411
+ # These rules intentionally exclude generic words such as "payment" and "purchase".
412
+ NARRATION_CATEGORY_RULES = (
413
+ ('credit_card', 'Credit Card Payment', 0.86, (
414
+ 'credit card bill', 'card bill payment', 'credit card payment',
415
+ )),
416
+ ('tax_payment', 'Tax Payment', 0.86, (
417
+ 'income tax', 'advance tax', 'tax challan', 'tax payment',
418
+ )),
419
+ ('insurance', 'Insurance Premium', 0.84, (
420
+ 'insurance premium', 'policy premium',
421
+ )),
422
+ ('medical', 'Medical', 0.80, (
423
+ 'pharmacy', 'hospital', 'medical store', 'clinic payment',
424
+ )),
425
+ ('education', 'Education', 0.80, (
426
+ 'school fee', 'college fee', 'tuition fee', 'course fee',
427
+ )),
428
+ ('trading_deposit', 'Trading Deposit', 0.82, (
429
+ 'trading account', 'broker deposit',
430
+ )),
431
+ ('investment', 'Investment', 0.82, (
432
+ 'mutual fund', 'sip investment', 'investment contribution',
433
+ )),
434
+ ('grocery', 'Grocery', 0.78, (
435
+ 'grocery', 'supermarket', 'kirana', 'provision store',
436
+ )),
437
+ ('food', 'Food', 0.76, (
438
+ 'restaurant', 'food order', 'cafe payment', 'meal payment',
439
+ )),
440
+ ('travel', 'Travel', 0.78, (
441
+ 'flight booking', 'hotel booking', 'cab ride', 'railway ticket',
442
+ 'travel booking',
443
+ )),
444
+ ('entertainment', 'Entertainment', 0.76, (
445
+ 'movie ticket', 'cinema', 'streaming subscription',
446
+ )),
447
+ ('bills', 'Utility Bill', 0.78, (
448
+ 'electricity bill', 'water bill', 'gas bill', 'mobile recharge',
449
+ 'broadband bill', 'utility bill',
450
+ )),
451
+ ('shopping', 'Shopping', 0.72, (
452
+ 'retail purchase', 'shopping order', 'apparel', 'electronics purchase',
453
+ )),
454
+ ('staff_salary', 'Staff Salary', 0.82, (
455
+ 'staff salary', 'maid salary', 'driver salary',
456
+ )),
457
+ ('donation', 'Donation', 0.78, ('donation', 'charity contribution')),
458
+ ('cash_withdrawal', 'Cash Withdrawal', 0.85, (
459
+ 'cash withdrawal', 'upi atm withdrawal',
460
+ )),
461
+ )
462
+
463
+
464
+ def _normalize_evidence(value: str) -> str:
465
+ """Normalize narration text for conservative token/phrase matching."""
466
+ return ' '.join(re.sub(r'[^a-z0-9]+', ' ', value.lower()).split())
467
+
468
+
469
+ def _contains_evidence(value: str, phrase: str) -> bool:
470
+ """Match a normalized token or phrase without accidental substrings."""
471
+ normalized_value = f" {_normalize_evidence(value)} "
472
+ normalized_phrase = _normalize_evidence(phrase)
473
+ return bool(normalized_phrase) and f" {normalized_phrase} " in normalized_value
474
+
475
+
476
+ def _handle_contains_keyword(handle: str, keyword: str) -> bool:
477
+ """Match exact token phrases or brand-prefixed handle tokens without infixes."""
478
+ if _contains_evidence(handle, keyword):
479
+ return True
480
+ compact_keyword = _normalize_evidence(keyword).replace(" ", "")
481
+ if len(compact_keyword) <= 4:
482
+ return False
483
+ handle_tokens = re.findall(r"[a-z0-9]+", handle.lower())
484
+ return any(token.startswith(compact_keyword) for token in handle_tokens)
485
+
486
+
487
+ def get_curated_transaction_override(
488
+ upi_handle: str,
489
+ sample_description: str = "",
490
+ ) -> Optional[dict]:
491
+ """Return only exact transaction markers that may outrank learned cache rows."""
492
+ handle_lower = (upi_handle or "").lower().strip()
493
+ description_lower = (sample_description or "").lower()
494
+ for marker, (display, category, is_income, confidence) in CURATED_TRANSACTION_MARKERS.items():
495
+ marker_pattern = rf"(?<![a-z0-9._@-]){re.escape(marker)}(?![a-z0-9._@-])"
496
+ if handle_lower == marker or re.search(marker_pattern, description_lower):
497
+ return {
498
+ "display_name": display,
499
+ "category": category,
500
+ "is_income": is_income,
501
+ "confidence": confidence,
502
+ "rationale": f"Curated transaction marker: {display}",
503
+ }
504
+ return None
505
+
506
+
507
+ def get_curated_merchant_override(
508
+ upi_handle: str,
509
+ sample_description: str = "",
510
+ ) -> Optional[dict]:
511
+ """Return curated markers and handle aliases for heuristic classification."""
512
+ curated = get_curated_transaction_override(upi_handle, sample_description)
513
+ if curated:
514
+ return curated
515
+ handle_lower = (upi_handle or "").lower().strip()
516
+ for alias_key, (display, category, is_income, confidence) in MERCHANT_ALIASES.items():
517
+ if _handle_contains_keyword(handle_lower, alias_key):
518
+ return {
519
+ "display_name": display,
520
+ "category": category,
521
+ "is_income": is_income,
522
+ "confidence": confidence,
523
+ "rationale": f"Merchant alias: {display}",
524
+ }
525
+ return None
526
+
527
+
528
+ def classify_upi_merchant(
529
+ upi_handle: str,
530
+ sample_description: str,
531
+ *,
532
+ learn: bool = True,
533
+ ) -> dict:
534
+ """Infer a UPI category, optionally learning stable handle evidence."""
535
+ handle_lower = (upi_handle or '').lower().strip()
536
+ description = sample_description or ''
537
+
538
+ curated = get_curated_merchant_override(upi_handle, description)
539
+ if curated:
540
+ return curated
541
+
542
+ # Exact handle identity always outranks incidental merchant text in narration.
543
+ sorted_map = sorted(HANDLE_CATEGORY_MAP.items(), key=lambda item: len(item[0]), reverse=True)
544
+ merchant_match = next(
545
+ (
546
+ (keyword, merchant)
547
+ for keyword, merchant in sorted_map
548
+ if merchant[1] != 'personal_transfer'
549
+ and _handle_contains_keyword(handle_lower, keyword)
550
+ ),
551
+ None,
552
+ )
553
+
554
+ # Only high-confidence, sufficiently specific merchant names may match narration.
555
+ if merchant_match is None:
556
+ merchant_match = next(
557
+ (
558
+ (keyword, merchant)
559
+ for keyword, merchant in sorted_map
560
+ if merchant[1] != 'personal_transfer'
561
+ and merchant[3] >= 0.80
562
+ and keyword not in GENERIC_NARRATION_KEYWORDS
563
+ and len(_normalize_evidence(keyword).replace(' ', '')) >= 4
564
+ and _contains_evidence(description, keyword)
565
+ ),
566
+ None,
567
+ )
568
+
569
+ if merchant_match is not None:
570
+ keyword, (display, category, is_income, confidence) = merchant_match
571
+ if category == 'credit_card' and 'cred' in keyword:
572
+ for part in description.split('/'):
573
+ part = part.strip()
574
+ if any(bank in part.upper() for bank in [
575
+ 'AXIS BANK', 'HDFC BANK', 'ICICI BANK', 'SBI', 'YES BANK',
576
+ 'KOTAK', 'IDFC', 'INDUSIND', 'AMERICAN EXPRESS',
577
+ 'STANDARD CHARTED', 'STANDARD CHARTERED', 'RBL', 'FEDERAL',
578
+ 'BANDHAN', 'YES BANK LIMITE',
579
+ ]):
580
+ display = f'CRED — {part.title()}'
581
+ break
582
+
583
+ # Specific known-merchant evidence is stable enough to learn for this handle.
584
+ if handle_lower and learn:
585
+ try:
586
+ store_merchant(
587
+ upi_handle,
588
+ display,
589
+ category,
590
+ is_income=is_income,
591
+ confidence=confidence,
592
+ sample_desc=description[:200],
593
+ )
594
+ except Exception:
595
+ pass
596
+ return {
597
+ 'display_name': display,
598
+ 'category': category,
599
+ 'is_income': is_income,
600
+ 'confidence': confidence,
601
+ 'rationale': f'Known UPI merchant evidence: {display}',
602
+ }
603
+
604
+ display = extract_display_name(handle_lower) or 'Unknown UPI counterparty'
605
+
606
+ # Purpose/category evidence is transaction-specific, so do not cache it by handle.
607
+ for category, generic_display, confidence, phrases in NARRATION_CATEGORY_RULES:
608
+ matched_phrase = next(
609
+ (phrase for phrase in phrases if _contains_evidence(description, phrase)),
610
+ None,
611
+ )
612
+ if matched_phrase:
613
+ return {
614
+ 'display_name': display if display != 'Unknown UPI counterparty' else generic_display,
615
+ 'category': category,
616
+ 'is_income': False,
617
+ 'confidence': confidence,
618
+ 'rationale': f'UPI narration evidence: {matched_phrase}',
619
+ }
620
+
621
+ local_part = handle_lower.split('@', 1)[0]
622
+ compact_local = re.sub(r'[^a-z0-9]', '', local_part)
623
+ mostly_numeric = bool(compact_local) and (
624
+ compact_local.isdigit()
625
+ or sum(character.isdigit() for character in compact_local) / len(compact_local) >= 0.8
626
+ )
627
+ explicit_personal = any(
628
+ _contains_evidence(description, marker) for marker in PERSONAL_TRANSFER_MARKERS
629
+ )
630
+
631
+ # Indian person-name P2P detection
632
+ local_part_fallback = handle_lower.split('@', 1)[0] if handle_lower else ''
633
+ # Remove non-alpha chars to evaluate the name
634
+ alpha_only = re.sub(r'[^a-z]', '', local_part_fallback)
635
+ # Skip masked handles (xxxxxxxxxx), repeated-char handles, and handles
636
+ # where the local part is mostly one repeated character — these are
637
+ # privacy-masked VPAs, not person names
638
+ unique_chars = set(alpha_only)
639
+ is_masked = len(unique_chars) <= 2 # e.g. "xxxxxxxxxx" → {'x'} → masked
640
+ # If handle local part is 5+ alphabetic chars, has no merchant keywords,
641
+ # no digits, no known brand indicators, and doesn't match any narration
642
+ # category → classify as personal_transfer at 0.40 confidence
643
+ if (len(alpha_only) >= 5
644
+ and not is_masked # exclude masked/repeated-char handles
645
+ and not any(kw in alpha_only for kw in (
646
+ 'paytm', 'phonepe', 'gpay', 'amazon', 'flipkart', 'zomato',
647
+ 'swiggy', 'blinkit', 'zepto', 'cred', 'bill', 'pay', 'tax',
648
+ 'loan', 'emi', 'insur', 'med', 'hospital', 'pharma', 'food',
649
+ 'mart', 'store', 'shop', 'bazar', 'mall', 'petrol', 'gas',
650
+ 'electric', 'water', 'broadband', 'recharge', 'netflix',
651
+ 'spotify', 'prime', 'hotstar', 'disney', 'apple', 'google',
652
+ 'flight', 'air', 'irctc', 'mmt', 'makemy', 'yatra', 'goibibo', 'cleartrip',
653
+ 'uber', 'ola', 'rapido', 'rent', 'pg', 'hostel',
654
+ ))
655
+ and not mostly_numeric # already handled above
656
+ and not explicit_personal # already handled above
657
+ and not merchant_match # no merchant evidence found
658
+ and not any(_contains_evidence(description, phrase)
659
+ for category, _, _, phrases in NARRATION_CATEGORY_RULES
660
+ for phrase in phrases)
661
+ ):
662
+ return {
663
+ 'display_name': 'UPI Transfer',
664
+ 'category': 'personal_transfer',
665
+ 'is_income': False,
666
+ 'confidence': 0.40,
667
+ 'rationale': 'Personal UPI transfer — no merchant evidence in handle or narration',
668
+ }
669
+
670
+ if mostly_numeric or explicit_personal:
671
+ return {
672
+ 'display_name': 'UPI Transfer',
673
+ 'category': 'personal_transfer',
674
+ 'is_income': False,
675
+ 'confidence': 0.55 if mostly_numeric else 0.60,
676
+ 'rationale': 'Strong personal-transfer evidence in UPI transaction',
677
+ }
678
+
679
+ return {
680
+ 'display_name': display,
681
+ 'category': 'unclassified',
682
+ 'is_income': False,
683
+ 'confidence': 0.35,
684
+ 'rationale': 'No reliable merchant or purpose evidence in UPI transaction',
685
+ }
686
+
687
+
688
+ def store_merchant(upi_handle: str, display_name: str, category: str,
689
+ is_income: bool = False, confidence: float = 0.85,
690
+ sample_desc: str = '') -> bool:
691
+ """Store a classified merchant in the database."""
692
+ conn = sqlite3.connect(str(DB_PATH))
693
+ try:
694
+ conn.execute(
695
+ """INSERT OR REPLACE INTO merchants
696
+ (upi_handle, display_name, category, is_income, confidence, sample_desc)
697
+ VALUES (?, ?, ?, ?, ?, ?)""",
698
+ (upi_handle.lower(), display_name, category,
699
+ 1 if is_income else 0, confidence, sample_desc[:200])
700
+ )
701
+ conn.commit()
702
+ return True
703
+ except Exception as e:
704
+ print(f"Error storing merchant {upi_handle}: {e}")
705
+ return False
706
+ finally:
707
+ conn.close()
708
+
709
+
710
+ def batch_store(merchants: list[dict]) -> int:
711
+ """Store multiple merchants at once. Each dict: {upi_handle, display_name, category, is_income, confidence, sample_desc}"""
712
+ conn = sqlite3.connect(str(DB_PATH))
713
+ count = 0
714
+ for m in merchants:
715
+ try:
716
+ conn.execute(
717
+ """INSERT OR REPLACE INTO merchants
718
+ (upi_handle, display_name, category, is_income, confidence, sample_desc)
719
+ VALUES (?, ?, ?, ?, ?, ?)""",
720
+ (m['upi_handle'].lower(), m['display_name'], m['category'],
721
+ 1 if m.get('is_income') else 0, m.get('confidence', 0.85),
722
+ m.get('sample_desc', '')[:200])
723
+ )
724
+ count += 1
725
+ except Exception:
726
+ pass
727
+ conn.commit()
728
+ conn.close()
729
+ return count
730
+
731
+
732
+ def get_db_stats() -> dict:
733
+ """Get statistics about the merchant database."""
734
+ conn = sqlite3.connect(str(DB_PATH))
735
+ total = conn.execute("SELECT COUNT(*) FROM merchants").fetchone()[0]
736
+ by_cat = conn.execute(
737
+ "SELECT category, COUNT(*) as cnt FROM merchants GROUP BY category ORDER BY cnt DESC"
738
+ ).fetchall()
739
+ conn.close()
740
+ return {
741
+ 'total_merchants': total,
742
+ 'categories': {cat: cnt for cat, cnt in by_cat}
743
+ }
744
+
745
+
746
+ # ─── Seed Data: Known merchants from regex patterns ───
747
+
748
+ SEED_MERCHANTS = [
749
+ # Trading / investments
750
+ {'upi_handle': 'zerodhabroking@', 'display_name': 'Zerodha', 'category': 'trading_deposit', 'is_income': False, 'confidence': 0.98},
751
+ {'upi_handle': 'indmoney@', 'display_name': 'INDmoney', 'category': 'investment', 'is_income': False, 'confidence': 0.90},
752
+
753
+ # Food delivery
754
+ {'upi_handle': 'zomato-order@pt', 'display_name': 'Zomato', 'category': 'food', 'is_income': False, 'confidence': 0.95},
755
+ {'upi_handle': 'swiggy@', 'display_name': 'Swiggy', 'category': 'food', 'is_income': False, 'confidence': 0.95},
756
+
757
+ # Shopping
758
+ {'upi_handle': 'amazon-pod@rap', 'display_name': 'Amazon', 'category': 'shopping', 'is_income': False, 'confidence': 0.90},
759
+ {'upi_handle': 'amazonsellerser', 'display_name': 'Amazon Seller Services', 'category': 'shopping', 'is_income': False, 'confidence': 0.85},
760
+ {'upi_handle': 'flipkart@', 'display_name': 'Flipkart', 'category': 'shopping', 'is_income': False, 'confidence': 0.90},
761
+
762
+ # Bills & utilities
763
+ {'upi_handle': 'gpay-utility@ok', 'display_name': 'Google Pay Utility', 'category': 'bills', 'is_income': False, 'confidence': 0.80},
764
+ {'upi_handle': 'youtube@axisba', 'display_name': 'YouTube Premium', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95},
765
+ {'upi_handle': 'playstore@axis', 'display_name': 'Google Play Store', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95},
766
+ {'upi_handle': 'netflix@', 'display_name': 'Netflix', 'category': 'entertainment', 'is_income': False, 'confidence': 0.95},
767
+
768
+ # Insurance (known providers)
769
+ {'upi_handle': 'nivabupa@', 'display_name': 'Niva Bupa Insurance', 'category': 'insurance', 'is_income': False, 'confidence': 0.92},
770
+
771
+ # Society / maintenance
772
+ {'upi_handle': 'paytm-mygate@pt', 'display_name': 'MyGate Society', 'category': 'bills', 'is_income': False, 'confidence': 0.90},
773
+ {'upi_handle': 'mygate.razorpa', 'display_name': 'MyGate', 'category': 'bills', 'is_income': False, 'confidence': 0.90},
774
+
775
+ # Travel
776
+ {'upi_handle': 'uber@', 'display_name': 'Uber', 'category': 'travel', 'is_income': False, 'confidence': 0.95},
777
+ {'upi_handle': 'ola@', 'display_name': 'Ola', 'category': 'travel', 'is_income': False, 'confidence': 0.95},
778
+ {'upi_handle': 'irctc@', 'display_name': 'IRCTC', 'category': 'travel', 'is_income': False, 'confidence': 0.95},
779
+ {'upi_handle': 'airindiaexpress', 'display_name': 'Air India Express', 'category': 'travel', 'is_income': False, 'confidence': 0.90},
780
+
781
+ # Credit card payments
782
+ {'upi_handle': 'cred@', 'display_name': 'CRED', 'category': 'credit_card', 'is_income': False, 'confidence': 0.95},
783
+
784
+ # Grocery
785
+ {'upi_handle': 'blinkit@', 'display_name': 'Blinkit', 'category': 'grocery', 'is_income': False, 'confidence': 0.95},
786
+ {'upi_handle': 'zepto@', 'display_name': 'Zepto', 'category': 'grocery', 'is_income': False, 'confidence': 0.95},
787
+ {'upi_handle': 'bigbasket@', 'display_name': 'BigBasket', 'category': 'grocery', 'is_income': False, 'confidence': 0.95},
788
+ ]
789
+
790
+
791
+ def seed_database():
792
+ """Initialize the merchant database with known merchants."""
793
+ count = batch_store(SEED_MERCHANTS)
794
+ print(f"Seeded {count} known merchants")
795
+ return count