SahilGoel commited on
Commit
32dfc35
·
verified ·
1 Parent(s): 4d3c0f5

Upload code/transaction_normalizer.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/transaction_normalizer.py +237 -0
code/transaction_normalizer.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stable, versioned transaction normalization boundary for TaxSage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from datetime import date, datetime
8
+ from typing import Any
9
+
10
+ from pipeline.bank_classifier import ClassifiedTransaction, RawTransaction, classify_with_rules
11
+
12
+ RULESET_VERSION = "2026.07.17.1"
13
+ MAX_NORMALIZE_BATCH = 500
14
+
15
+
16
+ class NormalizationError(ValueError):
17
+ """Raised when a transaction cannot satisfy the normalization contract."""
18
+
19
+
20
+ def _first_nonempty(record: dict[str, Any], *keys: str) -> Any:
21
+ for key in keys:
22
+ value = record.get(key)
23
+ if value is not None and str(value).strip():
24
+ return value
25
+ return None
26
+
27
+
28
+ def _parse_timestamp(value: Any) -> tuple[date, str]:
29
+ text = str(value or "").strip()
30
+ if not text:
31
+ raise NormalizationError("transaction date is required")
32
+
33
+ try:
34
+ parsed_date = date.fromisoformat(text)
35
+ return parsed_date, parsed_date.isoformat()
36
+ except ValueError:
37
+ pass
38
+
39
+ try:
40
+ parsed_datetime = datetime.fromisoformat(text.replace("Z", "+00:00"))
41
+ return parsed_datetime.date(), parsed_datetime.isoformat()
42
+ except ValueError:
43
+ pass
44
+
45
+ for pattern in ("%d/%m/%Y", "%d-%m-%Y"):
46
+ try:
47
+ parsed_date = datetime.strptime(text, pattern).date()
48
+ return parsed_date, parsed_date.isoformat()
49
+ except ValueError:
50
+ continue
51
+
52
+ raise NormalizationError("transaction date is invalid")
53
+
54
+
55
+ def _detect_channel(narration: str) -> str:
56
+ upper = narration.upper()
57
+ for channel in ("UPI", "NEFT", "IMPS", "RTGS", "ATM", "NACH", "ECS"):
58
+ if re.search(rf"\b{channel}\b", upper):
59
+ return channel
60
+ if re.search(r"\b(?:POS|ECOM|E-COM)\b", upper):
61
+ return "POS"
62
+ if re.search(r"\b(?:CARD|VISA|MASTERCARD|RUPAY)\b", upper):
63
+ return "CARD"
64
+ if re.search(r"\b(?:CHEQUE|CHQ)\b", upper):
65
+ return "CHEQUE"
66
+ if re.search(r"\b(?:INTERNET\s*BANKING|NETBANKING|I-BANK)\b", upper):
67
+ return "INTERNET_BANKING"
68
+ if re.search(r"\b(?:MOBILE\s*BANKING|MOB?BANK|M-BANK)\b", upper):
69
+ return "MOBILE_BANKING"
70
+ return "OTHER"
71
+
72
+
73
+ def _detect_reversal(narration: str) -> bool:
74
+ return bool(
75
+ re.search(r"\b(?:REVERSAL|REVERSED|REFUND|RVSL|CHARGEBACK)\b", narration, re.IGNORECASE)
76
+ )
77
+
78
+
79
+ def _detect_partial(narration: str) -> bool:
80
+ return bool(
81
+ re.search(
82
+ r"\b(?:PARTIAL|SPLIT|PART\s+\d+\s+OF\s+\d+)\b",
83
+ narration,
84
+ re.IGNORECASE,
85
+ )
86
+ )
87
+
88
+
89
+ def detect_transaction_metadata(narration: str) -> dict[str, Any]:
90
+ """Derive non-classifying metadata without database reads or writes."""
91
+ return {
92
+ "channel": _detect_channel(narration),
93
+ "is_reversal": _detect_reversal(narration),
94
+ "is_partial": _detect_partial(narration),
95
+ }
96
+
97
+
98
+ def _classification_path(classified: ClassifiedTransaction) -> str:
99
+ rationale = classified.rationale.lower()
100
+ if rationale.startswith("merchant db:"):
101
+ return "merchant_db"
102
+ if rationale.startswith("known upi merchant evidence:"):
103
+ return "merchant_match"
104
+ if rationale.startswith("upi narration evidence:"):
105
+ return "narration_purpose"
106
+ if rationale.startswith("strong personal-transfer evidence"):
107
+ return "personal_transfer"
108
+ if rationale.startswith("matched rule:"):
109
+ return "regex_rule"
110
+ if rationale.startswith("manual override"):
111
+ return "manual_override"
112
+ return "unclassified"
113
+
114
+
115
+ def _confidence_level(confidence: float) -> str:
116
+ if confidence >= 0.85:
117
+ return "HIGH"
118
+ if confidence >= 0.60:
119
+ return "MEDIUM"
120
+ return "LOW"
121
+
122
+
123
+ def serialize_classified_transaction(
124
+ classified: ClassifiedTransaction,
125
+ *,
126
+ account: str = "default",
127
+ description: str | None = None,
128
+ ) -> dict[str, Any]:
129
+ """Serialize an existing classification with additive normalization metadata."""
130
+ narration = classified.raw.description if description is None else description
131
+ confidence = min(1.0, max(0.0, float(classified.confidence)))
132
+ rationale = classified.rationale or "No reliable classification evidence"
133
+ path = _classification_path(classified)
134
+ raw_date = classified.raw.date
135
+ raw_date_text = str(raw_date)
136
+ normalized_date = (
137
+ ""
138
+ if raw_date_text == "NaT"
139
+ else raw_date.isoformat() if hasattr(raw_date, "isoformat") else raw_date_text
140
+ )
141
+ return {
142
+ "date": normalized_date,
143
+ "description": narration,
144
+ "amount": float(classified.raw.amount),
145
+ "type": classified.raw.type,
146
+ "category": classified.category or "unclassified",
147
+ "confidence": confidence,
148
+ "is_income": bool(classified.is_income),
149
+ "is_expense": bool(classified.is_expense),
150
+ "counterparty": classified.counterparty,
151
+ "rationale": rationale,
152
+ "account": account or "default",
153
+ "channel": _detect_channel(classified.raw.description),
154
+ "is_reversal": _detect_reversal(classified.raw.description),
155
+ "is_partial": _detect_partial(classified.raw.description),
156
+ "classification_path": path,
157
+ "ruleset_version": RULESET_VERSION,
158
+ "explain": {"path": path, "rationale": rationale},
159
+ }
160
+
161
+
162
+ def normalize_transaction(
163
+ record: dict[str, Any],
164
+ *,
165
+ index: int = 0,
166
+ require_timestamp: bool = True,
167
+ ) -> dict[str, Any]:
168
+ """Normalize one validated transaction record into the stable TaxSage schema."""
169
+ if not isinstance(record, dict):
170
+ raise NormalizationError("transaction must be an object")
171
+
172
+ narration_value = _first_nonempty(record, "raw", "description")
173
+ narration = str(narration_value or "").strip()
174
+ if not narration:
175
+ raise NormalizationError("transaction narration is required")
176
+
177
+ amount_value = record.get("amount")
178
+ if isinstance(amount_value, bool):
179
+ raise NormalizationError("transaction amount must be finite")
180
+ try:
181
+ amount = float(amount_value)
182
+ except (TypeError, ValueError) as error:
183
+ raise NormalizationError("transaction amount must be finite") from error
184
+ if not math.isfinite(amount):
185
+ raise NormalizationError("transaction amount must be finite")
186
+ if amount < 0:
187
+ raise NormalizationError("transaction amount must be non-negative")
188
+
189
+ transaction_type = str(record.get("type", "")).strip().lower()
190
+ if transaction_type not in {"credit", "debit"}:
191
+ raise NormalizationError("transaction type must be credit or debit")
192
+
193
+ timestamp_value = _first_nonempty(record, "timestamp", "date")
194
+ if (timestamp_value is None or str(timestamp_value).strip() == "NaT") and not require_timestamp:
195
+ raw_date, normalized_timestamp = date.min, ""
196
+ else:
197
+ raw_date, normalized_timestamp = _parse_timestamp(timestamp_value)
198
+ raw = RawTransaction(
199
+ date=raw_date,
200
+ description=narration,
201
+ type=transaction_type,
202
+ amount=amount,
203
+ )
204
+ classified = classify_with_rules(raw, learn_merchants=False)
205
+ if classified is None:
206
+ classified = ClassifiedTransaction(
207
+ raw=raw,
208
+ category="unclassified",
209
+ confidence=0.30,
210
+ rationale="No reliable classification evidence",
211
+ is_income=False,
212
+ is_expense=transaction_type == "debit",
213
+ )
214
+
215
+ confidence = min(1.0, max(0.0, float(classified.confidence)))
216
+ rationale = classified.rationale or "No reliable classification evidence"
217
+ return {
218
+ "id": str(record.get("id", index)),
219
+ "raw": narration,
220
+ "merchant": classified.counterparty,
221
+ "category": classified.category or "unclassified",
222
+ "transaction_type": transaction_type.upper(),
223
+ "channel": _detect_channel(narration),
224
+ "amount": amount,
225
+ "normalized_timestamp": normalized_timestamp,
226
+ "is_reversal": _detect_reversal(narration),
227
+ "is_partial": _detect_partial(narration),
228
+ "confidence": confidence,
229
+ "confidence_level": _confidence_level(confidence),
230
+ "is_income": bool(classified.is_income),
231
+ "is_expense": bool(classified.is_expense),
232
+ "explain": {
233
+ "path": _classification_path(classified),
234
+ "rationale": rationale,
235
+ },
236
+ "ruleset_version": RULESET_VERSION,
237
+ }