Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Parser: multi-issuer support (HDFC/Axis/IDFC/Kotak), forex/fee fixes, city-split, brand aliases
Browse files- app/merchants.py +32 -0
- app/statement_parser.py +161 -14
app/merchants.py
CHANGED
|
@@ -114,6 +114,38 @@ MERCHANT_MAP: Dict[str, Tuple[str, Optional[str]]] = {
|
|
| 114 |
"insurance": ("insurance", None),
|
| 115 |
"premium": ("insurance", None),
|
| 116 |
"rent": ("rent", None),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
# wallet
|
| 118 |
"paytm wallet": ("wallet_load", None),
|
| 119 |
"wallet load": ("wallet_load", None),
|
|
|
|
| 114 |
"insurance": ("insurance", None),
|
| 115 |
"premium": ("insurance", None),
|
| 116 |
"rent": ("rent", None),
|
| 117 |
+
# ride-hailing / transport
|
| 118 |
+
"uber": ("transport", "uber"),
|
| 119 |
+
"rapido": ("transport", "rapido"),
|
| 120 |
+
"olacabs": ("transport", "ola"),
|
| 121 |
+
"ola cabs": ("transport", "ola"),
|
| 122 |
+
# developer tools / online services (kept neutral so rewards aren't distorted)
|
| 123 |
+
"github": ("online_shopping", "github"),
|
| 124 |
+
"notion": ("online_shopping", "notion"),
|
| 125 |
+
"colab": ("online_shopping", None),
|
| 126 |
+
"google": ("online_shopping", "google"),
|
| 127 |
+
"figma": ("online_shopping", None),
|
| 128 |
+
"vercel": ("online_shopping", None),
|
| 129 |
+
"digitalocean": ("online_shopping", None),
|
| 130 |
+
# test prep / education (correct category — GMAT/MBA prep is education spend)
|
| 131 |
+
"gmatclub": ("education", None),
|
| 132 |
+
"gmat": ("education", None),
|
| 133 |
+
"targettestprep": ("education", None),
|
| 134 |
+
"target test prep": ("education", None),
|
| 135 |
+
"rocketblocks": ("education", None),
|
| 136 |
+
"coursera": ("education", None),
|
| 137 |
+
"udemy": ("education", None),
|
| 138 |
+
"leetcode": ("education", None),
|
| 139 |
+
"kaplan": ("education", None),
|
| 140 |
+
"upgrad": ("education", None),
|
| 141 |
+
# more Indian brands
|
| 142 |
+
"poshvine": ("general", None),
|
| 143 |
+
"urbanclap": ("general", "urban_company"),
|
| 144 |
+
"urban company": ("general", "urban_company"),
|
| 145 |
+
"lenskart": ("online_shopping", "lenskart"),
|
| 146 |
+
"mamaearth": ("online_shopping", "mamaearth"),
|
| 147 |
+
"decathlon": ("apparel", "decathlon"),
|
| 148 |
+
"fashnear": ("online_shopping", "meesho"),
|
| 149 |
# wallet
|
| 150 |
"paytm wallet": ("wallet_load", None),
|
| 151 |
"wallet load": ("wallet_load", None),
|
app/statement_parser.py
CHANGED
|
@@ -43,9 +43,12 @@ _DATE_TOKEN_RE = re.compile(
|
|
| 43 |
r"(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{1,2}[ \-]\w{3}[ \-]\d{2,4}|\d{4}-\d{2}-\d{2})"
|
| 44 |
)
|
| 45 |
# Rows whose description is really a payment/credit, not a purchase.
|
|
|
|
|
|
|
|
|
|
| 46 |
_PAYMENT_RE = re.compile(
|
| 47 |
r"\b(payment received|credit card payment|card payment|neft|imps|rtgs|upi[\s/-]*payment|"
|
| 48 |
-
r"autopay|auto pay|bbps|nach|e-?mandate|refund|reversal|cashback received)
|
| 49 |
re.IGNORECASE,
|
| 50 |
)
|
| 51 |
|
|
@@ -53,17 +56,82 @@ _PAYMENT_RE = re.compile(
|
|
| 53 |
# not purchases the user chooses a card for, so they're excluded from the analysis.
|
| 54 |
_CHARGES_RE = re.compile(
|
| 55 |
r"(\bigst\b|\bcgst\b|\bsgst\b|\bgst\b|finance charge|interest charge|\binterest\b|"
|
| 56 |
-
r"late fee|membership fee|annual fee|joining fee|renewal fee|cash advance fee|"
|
| 57 |
-
r"over\s?limit fee|fuel surcharge|surcharge
|
|
|
|
|
|
|
| 58 |
re.IGNORECASE,
|
| 59 |
)
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def _parse_date(s: str) -> Optional[str]:
|
| 63 |
"""Parse a date that may carry a trailing timestamp (e.g. '17/04/2026 22:46:14')."""
|
| 64 |
s = (s or "").strip()
|
| 65 |
if not s:
|
| 66 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
# pull just the date token if there's a time or extra text alongside it
|
| 68 |
tok = _DATE_TOKEN_RE.search(s)
|
| 69 |
candidates = [s]
|
|
@@ -117,7 +185,7 @@ def _normalize_row(date_s: str, desc: str, amount: float) -> Dict:
|
|
| 117 |
return {
|
| 118 |
"date": _parse_date(date_s) or date_s,
|
| 119 |
"description": desc.strip()[:120],
|
| 120 |
-
"merchant": resolved["matched_merchant"] or
|
| 121 |
"category": resolved["category"],
|
| 122 |
"brand_key": resolved["brand_key"],
|
| 123 |
"amount": round(abs(amount), 2),
|
|
@@ -186,6 +254,8 @@ def parse_csv(content: bytes) -> List[Dict]:
|
|
| 186 |
direction = r[ki] if 0 <= ki < len(r) else ""
|
| 187 |
if _is_credit(direction, desc):
|
| 188 |
continue
|
|
|
|
|
|
|
| 189 |
out.append(_normalize_row(iso, desc, amount))
|
| 190 |
return out # header found: trust it (even if every row was a credit/payment)
|
| 191 |
|
|
@@ -206,12 +276,14 @@ def parse_csv(content: bytes) -> List[Dict]:
|
|
| 206 |
desc = max(others, key=len) if others else " ".join(r)
|
| 207 |
if _is_credit("", desc):
|
| 208 |
continue
|
|
|
|
|
|
|
| 209 |
out.append(_normalize_row(_parse_date(date_field), desc, amount))
|
| 210 |
return out
|
| 211 |
|
| 212 |
|
| 213 |
# a date anywhere on a line: dd/mm/yyyy, dd-mm-yy, dd Mon yy, dd-MON-yy, dd/Mon/yyyy
|
| 214 |
-
_PDF_DATE_RE = re.compile(r"(\d{1,2}[/\-. ](?:\d{1,2}|[A-Za-z]{3,9})[/\-. ]\d{2,4})")
|
| 215 |
_SIGNED_AMT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})")
|
| 216 |
# lines that are clearly not transactions (summary / headers / footers)
|
| 217 |
_PDF_STOP = (
|
|
@@ -221,6 +293,43 @@ _PDF_STOP = (
|
|
| 221 |
)
|
| 222 |
_PDF_START = ("transaction details", "your transactions", "transaction date", "date transaction")
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
class _PdfRow:
|
| 226 |
pass
|
|
@@ -230,6 +339,10 @@ def _parse_pdf_lines(lines: List[str], gated: bool = True) -> List[Dict]:
|
|
| 230 |
out: List[Dict] = []
|
| 231 |
started = not gated # ungated fallback: parse from the top
|
| 232 |
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
for raw in lines:
|
| 235 |
line = (raw or "").strip()
|
|
@@ -243,10 +356,12 @@ def _parse_pdf_lines(lines: List[str], gated: bool = True) -> List[Dict]:
|
|
| 243 |
continue
|
| 244 |
|
| 245 |
dm = _PDF_DATE_RE.search(line)
|
|
|
|
|
|
|
| 246 |
amts = list(_SIGNED_AMT_RE.finditer(line))
|
| 247 |
|
| 248 |
-
if
|
| 249 |
-
iso = _parse_date(dm
|
| 250 |
if not iso: # date-like but not a real date -> buffer text
|
| 251 |
if re.search(r"[A-Za-z]", line):
|
| 252 |
pending = (pending + [line])[-2:]
|
|
@@ -259,26 +374,58 @@ def _parse_pdf_lines(lines: List[str], gated: bool = True) -> List[Dict]:
|
|
| 259 |
if amount == 0: # FX-only / zero rows
|
| 260 |
pending = []
|
| 261 |
continue
|
| 262 |
-
desc = line[:amt_m.start()].replace(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
desc = re.sub(r"\b\d{6,}\b", " ", desc) # strip long reference numbers
|
|
|
|
| 264 |
desc = re.sub(r"\b0\.00\b", " ", desc) # strip the FX (international) 0.00 column
|
| 265 |
desc = re.sub(r"\b[DC]R\b", " ", desc, flags=re.I)
|
| 266 |
desc = re.sub(r"(?<![A-Za-z])[rR](?=\d)", " ", desc) # ₹ rendered as 'r'
|
| 267 |
-
desc = re.sub(r"\s
|
| 268 |
-
|
| 269 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
pending = []
|
| 271 |
if is_credit:
|
| 272 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
# ungated fallback: require a real merchant name so summary/total lines
|
| 274 |
# (which have no description) aren't mistaken for transactions
|
| 275 |
if not gated and not re.search(r"[A-Za-z]", desc):
|
| 276 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
out.append(_normalize_row(iso, desc or "Transaction", amount))
|
| 278 |
-
elif not dm and re.search(r"[A-Za-z]", line) and len(line) <= 60 and not any(k in low for k in _PDF_STOP):
|
| 279 |
-
pending = (pending + [line])[-2:] # candidate wrapped merchant name
|
| 280 |
else:
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
return out
|
| 283 |
|
| 284 |
|
|
|
|
| 43 |
r"(\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}|\d{1,2}[ \-]\w{3}[ \-]\d{2,4}|\d{4}-\d{2}-\d{2})"
|
| 44 |
)
|
| 45 |
# Rows whose description is really a payment/credit, not a purchase.
|
| 46 |
+
# No trailing \b: HDFC glues the next word onto "PAYMENT" ("CREDIT CARD PAYMENTNet
|
| 47 |
+
# Banking"), which a trailing word-boundary would miss, letting a bill payment be
|
| 48 |
+
# counted as spend.
|
| 49 |
_PAYMENT_RE = re.compile(
|
| 50 |
r"\b(payment received|credit card payment|card payment|neft|imps|rtgs|upi[\s/-]*payment|"
|
| 51 |
+
r"autopay|auto pay|bbps|nach|e-?mandate|refund|reversal|cashback received)",
|
| 52 |
re.IGNORECASE,
|
| 53 |
)
|
| 54 |
|
|
|
|
| 56 |
# not purchases the user chooses a card for, so they're excluded from the analysis.
|
| 57 |
_CHARGES_RE = re.compile(
|
| 58 |
r"(\bigst\b|\bcgst\b|\bsgst\b|\bgst\b|finance charge|interest charge|\binterest\b|"
|
| 59 |
+
r"late fee|late payment|membership fee|annual fee|joining fee|renewal fee|cash advance fee|"
|
| 60 |
+
r"over\s?limit fee|fuel surcharge|surcharge|"
|
| 61 |
+
r"forex markup|fx markup|markup fee|currency conversion|cross[\s-]?currency|"
|
| 62 |
+
r"reward point|\bfee reversal\b|\bemi (?:principal|interest)\b)",
|
| 63 |
re.IGNORECASE,
|
| 64 |
)
|
| 65 |
|
| 66 |
|
| 67 |
+
# Foreign-currency codes on international lines, and helpers to drop forex-conversion
|
| 68 |
+
# detail sub-lines and to render a clean merchant name from a raw descriptor.
|
| 69 |
+
_CCY_RE = re.compile(r"\b(usd|eur|gbp|aed|sgd|jpy|aud|cad|chf|hkd|thb|myr|sar|qar|cny)\b", re.IGNORECASE)
|
| 70 |
+
_FOREX_DETAIL_RE = re.compile(r"^(convert|conversion|fx|forex|foreign\s*currency|markup|intl\s*txn)\b", re.IGNORECASE)
|
| 71 |
+
_CCY_AMT_RE = re.compile(_CCY_RE.pattern + r"\s*[\d.,]+", re.IGNORECASE)
|
| 72 |
+
_PREFIX_RE = re.compile(r"^\s*(pos|vps|ecom(m)?|imps|neft|rtgs|upi|ach|nach|intl|int'?l)[\s/*:.\-]+", re.IGNORECASE)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _is_forex_detail_line(desc: str) -> bool:
|
| 76 |
+
d = (desc or "").strip()
|
| 77 |
+
return bool(_FOREX_DETAIL_RE.match(d) and _CCY_RE.search(d))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _title(s: str) -> str:
|
| 81 |
+
return re.sub(r"\bPaypal\b", "PayPal", s.title())
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# HDFC glues the city onto the merchant with no space ("Entertainmenthyderabad",
|
| 85 |
+
# "Kurlamumbai"). Split a trailing known city off, longest names first.
|
| 86 |
+
_CITIES = tuple(sorted((
|
| 87 |
+
"navimumbai", "newdelhi", "bengaluru", "bangalore", "hyderabad", "ahmedabad",
|
| 88 |
+
"coimbatore", "visakhapatnam", "bhubaneswar", "chandigarh", "gurugram", "gurgaon",
|
| 89 |
+
"mumbai", "delhi", "chennai", "kolkata", "pune", "noida", "jaipur", "lucknow",
|
| 90 |
+
"kochi", "indore", "nagpur", "surat", "thane", "vadodara", "bhopal", "patna",
|
| 91 |
+
"ludhiana", "agra", "nashik", "faridabad", "ghaziabad", "rajkot", "meerut",
|
| 92 |
+
"amritsar", "mysuru", "mysore", "goa", "kanpur", "varanasi", "guwahati",
|
| 93 |
+
), key=len, reverse=True))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _split_glued_city(s: str) -> str:
|
| 97 |
+
low = s.lower()
|
| 98 |
+
for c in _CITIES:
|
| 99 |
+
if low.endswith(c) and len(s) > len(c) + 2 and s[-len(c) - 1] not in " ,":
|
| 100 |
+
return s[:-len(c)] + " " + s[-len(c):]
|
| 101 |
+
return s
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _clean_merchant(raw: str) -> str:
|
| 105 |
+
original = (raw or "").strip()
|
| 106 |
+
strip = lambda x: re.sub(r"^[\s*.,\-]+|[\s*.,\-]+$", "", x).strip()
|
| 107 |
+
if re.search(r"paypal", original, re.IGNORECASE):
|
| 108 |
+
m = re.search(r"paypal\s*\*?\s*([a-z][a-z0-9 &._-]{1,24})", original, re.IGNORECASE)
|
| 109 |
+
sub = ""
|
| 110 |
+
if m and m.group(1):
|
| 111 |
+
sub = strip(re.sub(r"[\d.,]+", "", re.sub(r"\b(convert|conversion|usd|eur|gbp)\b", "", m.group(1), flags=re.IGNORECASE)))
|
| 112 |
+
sub = sub.upper() if len(sub) <= 4 else _title(sub)
|
| 113 |
+
return f"PayPal · {sub}" if len(sub) > 1 else "PayPal"
|
| 114 |
+
s = _PREFIX_RE.sub("", original)
|
| 115 |
+
s = _CCY_AMT_RE.sub(" ", s)
|
| 116 |
+
s = re.sub(r"\b(convert|conversion)\b", " ", s, flags=re.IGNORECASE)
|
| 117 |
+
s = re.sub(r"#\S*", " ", s)
|
| 118 |
+
s = re.sub(r"\d[\d.,]{3,}", " ", s)
|
| 119 |
+
s = re.sub(r"\*+", " ", s)
|
| 120 |
+
s = strip(re.sub(r"\s{2,}", " ", s))
|
| 121 |
+
if len(s) < 2:
|
| 122 |
+
return original[:40] or "Transaction"
|
| 123 |
+
return _title(_split_glued_city(s))[:40]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
def _parse_date(s: str) -> Optional[str]:
|
| 127 |
"""Parse a date that may carry a trailing timestamp (e.g. '17/04/2026 22:46:14')."""
|
| 128 |
s = (s or "").strip()
|
| 129 |
if not s:
|
| 130 |
return None
|
| 131 |
+
# Axis prints the year with a leading apostrophe ("01 Jan '26") - drop it so
|
| 132 |
+
# strptime's %y matches, and collapse the double space it leaves behind.
|
| 133 |
+
s = re.sub(r"'\s*(\d)", r"\1", s)
|
| 134 |
+
s = re.sub(r"\s{2,}", " ", s)
|
| 135 |
# pull just the date token if there's a time or extra text alongside it
|
| 136 |
tok = _DATE_TOKEN_RE.search(s)
|
| 137 |
candidates = [s]
|
|
|
|
| 185 |
return {
|
| 186 |
"date": _parse_date(date_s) or date_s,
|
| 187 |
"description": desc.strip()[:120],
|
| 188 |
+
"merchant": resolved["matched_merchant"] or _clean_merchant(desc),
|
| 189 |
"category": resolved["category"],
|
| 190 |
"brand_key": resolved["brand_key"],
|
| 191 |
"amount": round(abs(amount), 2),
|
|
|
|
| 254 |
direction = r[ki] if 0 <= ki < len(r) else ""
|
| 255 |
if _is_credit(direction, desc):
|
| 256 |
continue
|
| 257 |
+
if _is_forex_detail_line(desc): # drop forex conversion sub-lines ("Convert USD 324.50")
|
| 258 |
+
continue
|
| 259 |
out.append(_normalize_row(iso, desc, amount))
|
| 260 |
return out # header found: trust it (even if every row was a credit/payment)
|
| 261 |
|
|
|
|
| 276 |
desc = max(others, key=len) if others else " ".join(r)
|
| 277 |
if _is_credit("", desc):
|
| 278 |
continue
|
| 279 |
+
if _is_forex_detail_line(desc):
|
| 280 |
+
continue
|
| 281 |
out.append(_normalize_row(_parse_date(date_field), desc, amount))
|
| 282 |
return out
|
| 283 |
|
| 284 |
|
| 285 |
# a date anywhere on a line: dd/mm/yyyy, dd-mm-yy, dd Mon yy, dd-MON-yy, dd/Mon/yyyy
|
| 286 |
+
_PDF_DATE_RE = re.compile(r"(\d{1,2}[/\-. ](?:\d{1,2}|[A-Za-z]{3,9})[/\-. ]'?\d{2,4})")
|
| 287 |
_SIGNED_AMT_RE = re.compile(r"(-?\d[\d,]*\.\d{2})")
|
| 288 |
# lines that are clearly not transactions (summary / headers / footers)
|
| 289 |
_PDF_STOP = (
|
|
|
|
| 293 |
)
|
| 294 |
_PDF_START = ("transaction details", "your transactions", "transaction date", "date transaction")
|
| 295 |
|
| 296 |
+
# Amex prints month-first dates with no year on the row ("June 03"); the year comes
|
| 297 |
+
# from the statement period. Support that with a separate matcher + year injection.
|
| 298 |
+
_MONTHS = {m: i for i, m in enumerate(
|
| 299 |
+
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)}
|
| 300 |
+
_MONTH_DAY_RE = re.compile(
|
| 301 |
+
r"\b((?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2})(?!\d)", re.I)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _parse_month_day(txt: str, year: int) -> Optional[str]:
|
| 305 |
+
m = re.match(r"([a-z]{3})[a-z]*\.?\s+(\d{1,2})", (txt or "").strip(), re.I)
|
| 306 |
+
if not m:
|
| 307 |
+
return None
|
| 308 |
+
mo = _MONTHS.get(m.group(1).lower())
|
| 309 |
+
day = int(m.group(2))
|
| 310 |
+
if not mo or not (1 <= day <= 31):
|
| 311 |
+
return None
|
| 312 |
+
return f"{year:04d}-{mo:02d}-{day:02d}"
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# Descriptor noise seen on Kotak (and others): payment-gateway prefixes, embedded URLs,
|
| 316 |
+
# brand domains, and the glued "SpendsArea" MCC-group column printed before the amount.
|
| 317 |
+
_DOMAIN_RE = re.compile(r"\b([a-z][\w-]*)\.(?:com|in|org|net|io|co|edu|gov|sg|us|uk|app)\w*", re.I)
|
| 318 |
+
_AGG_PREFIX_RE = re.compile(r"\b(raz|pyu|payu|ccbill|billdesk|pinelabs)\s*\*\s*", re.I)
|
| 319 |
+
_SPENDS_AREA_RE = re.compile(
|
| 320 |
+
r"\s+(services|education|automotive|apparel(?:\s*&\s*accessories)?|"
|
| 321 |
+
r"transport\s*&?\s*freight|freight|quasi\s*cash|financial\s*services|"
|
| 322 |
+
r"government\s*services|professional\s*services|utilities|telecom|insurance)\s*$", re.I)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _strip_descriptor_noise(s: str) -> str:
|
| 326 |
+
s = re.sub(r"https?[:/]*\S*", " ", s, flags=re.I) # URLs, incl. glued "httpsgmat.."
|
| 327 |
+
s = re.sub(r"\([^)]*\)", " ", s) # "(*ConverttoEMI)" markers
|
| 328 |
+
s = re.sub(r"\s*\([^)]*$", " ", s) # a marker truncated at a line wrap "(*Conv"
|
| 329 |
+
s = _AGG_PREFIX_RE.sub(" ", s) # "RAZ*RAPIDO" -> "RAPIDO"
|
| 330 |
+
s = _DOMAIN_RE.sub(r"\1", s) # "github.com" -> "github"
|
| 331 |
+
return re.sub(r"\s{2,}", " ", s).strip()
|
| 332 |
+
|
| 333 |
|
| 334 |
class _PdfRow:
|
| 335 |
pass
|
|
|
|
| 339 |
out: List[Dict] = []
|
| 340 |
started = not gated # ungated fallback: parse from the top
|
| 341 |
pending: List[str] = [] # buffered wrapped merchant-name lines (IDFC style)
|
| 342 |
+
# Year for month-first dates that omit it (Amex "June 03"): take the latest 4-digit
|
| 343 |
+
# year printed anywhere on the statement, else the current year.
|
| 344 |
+
_yrs = re.findall(r"\b(20\d{2})\b", "\n".join(lines))
|
| 345 |
+
default_year = int(max(_yrs)) if _yrs else datetime.now().year
|
| 346 |
|
| 347 |
for raw in lines:
|
| 348 |
line = (raw or "").strip()
|
|
|
|
| 356 |
continue
|
| 357 |
|
| 358 |
dm = _PDF_DATE_RE.search(line)
|
| 359 |
+
md = None if dm else _MONTH_DAY_RE.search(line) # Amex month-first fallback
|
| 360 |
+
date_txt = dm.group(1) if dm else (md.group(1) if md else None)
|
| 361 |
amts = list(_SIGNED_AMT_RE.finditer(line))
|
| 362 |
|
| 363 |
+
if date_txt and amts:
|
| 364 |
+
iso = _parse_date(date_txt) if dm else _parse_month_day(date_txt, default_year)
|
| 365 |
if not iso: # date-like but not a real date -> buffer text
|
| 366 |
if re.search(r"[A-Za-z]", line):
|
| 367 |
pending = (pending + [line])[-2:]
|
|
|
|
| 374 |
if amount == 0: # FX-only / zero rows
|
| 375 |
pending = []
|
| 376 |
continue
|
| 377 |
+
desc = line[:amt_m.start()].replace(date_txt, " ")
|
| 378 |
+
desc = desc.replace("₹", " ") # Axis prints "₹ 980.00"
|
| 379 |
+
desc = re.sub(r"\b(?:inr|rs)\.?\b", " ", desc, flags=re.I) # RBL "INR 1,899.00" prefix
|
| 380 |
+
desc = _strip_descriptor_noise(desc) # Kotak URLs / gateway prefix / EMI markers / domains
|
| 381 |
+
desc = re.sub(r"^[\s|]*\d{1,2}:\d{2}(?::\d{2})?\s+", " ", desc) # HDFC leading time "22:16"
|
| 382 |
+
desc = re.sub(r"\s\+\s*\d{1,3}\b", " ", desc) # HDFC "+ 15" reward-point marker
|
| 383 |
desc = re.sub(r"\b\d{6,}\b", " ", desc) # strip long reference numbers
|
| 384 |
+
desc = re.sub(r"#\w+", " ", desc) # Axis "#HJ1S1I6CZYEUZ9" transaction refs
|
| 385 |
desc = re.sub(r"\b0\.00\b", " ", desc) # strip the FX (international) 0.00 column
|
| 386 |
desc = re.sub(r"\b[DC]R\b", " ", desc, flags=re.I)
|
| 387 |
desc = re.sub(r"(?<![A-Za-z])[rR](?=\d)", " ", desc) # ₹ rendered as 'r'
|
| 388 |
+
desc = re.sub(r"\s+[CD]\s*$", " ", desc) # HDFC trailing debit/credit column letter
|
| 389 |
+
desc = _SPENDS_AREA_RE.sub(" ", desc) # Kotak "... Services"/"... Automotive" MCC column
|
| 390 |
+
desc = re.sub(r"\s+in\s*$", " ", desc, flags=re.I) # trailing India country code
|
| 391 |
+
desc = re.sub(r"\b(\w+)(\s+\1\b)+", r"\1", desc, flags=re.I) # collapse repeated words ("Gmatclub Gmatclub")
|
| 392 |
+
desc = re.sub(r"\s{2,}", " ", desc).strip(" ,|+")
|
| 393 |
+
# A real merchant name is one that survives stripping the issuer's EMI-convert
|
| 394 |
+
# marker and any "USD 324.50" forex fragment. IDFC prints an international
|
| 395 |
+
# purchase as "<merchant wrapped above>\n<date> Convert USD 324.50 <INR>", so
|
| 396 |
+
# the amount line itself has no merchant - recover it from the wrapped buffer.
|
| 397 |
+
core = re.sub(r"\b(convert|conversion|fx|forex)\b", " ", desc, flags=re.I)
|
| 398 |
+
core = _CCY_AMT_RE.sub(" ", core)
|
| 399 |
+
core = _CCY_RE.sub(" ", core)
|
| 400 |
+
core = re.sub(r"[\d.,]+", " ", core).strip(" ,|*")
|
| 401 |
+
if len(core) < 2: # inline text is only a forex marker
|
| 402 |
+
desc = " ".join(pending).strip(" ,|") or desc
|
| 403 |
pending = []
|
| 404 |
if is_credit:
|
| 405 |
continue
|
| 406 |
+
# Re-run the credit/charge check on the RESOLVED description: on wrapped
|
| 407 |
+
# layouts (IDFC) the merchant text comes from `pending`, so the raw `line`
|
| 408 |
+
# check above never sees words like "Interest charges" / "Forex Markup Fee".
|
| 409 |
+
if _is_credit("", desc) or _CHARGES_RE.search(desc):
|
| 410 |
+
continue
|
| 411 |
# ungated fallback: require a real merchant name so summary/total lines
|
| 412 |
# (which have no description) aren't mistaken for transactions
|
| 413 |
if not gated and not re.search(r"[A-Za-z]", desc):
|
| 414 |
continue
|
| 415 |
+
# last resort: an international row whose merchant never resolved - label it
|
| 416 |
+
# rather than showing the raw "Convert USD 324.50" forex fragment
|
| 417 |
+
if _is_forex_detail_line(desc):
|
| 418 |
+
desc = "International transaction"
|
| 419 |
out.append(_normalize_row(iso, desc or "Transaction", amount))
|
|
|
|
|
|
|
| 420 |
else:
|
| 421 |
+
# Candidate wrapped merchant name (no amount on this line). Some issuers
|
| 422 |
+
# (RBL) repeat the date on the continuation line, so strip any leading date
|
| 423 |
+
# before buffering it for the amount row that follows.
|
| 424 |
+
nm = (line.replace(date_txt, " ") if date_txt else line).strip(" ,|")
|
| 425 |
+
if re.search(r"[A-Za-z]", nm) and len(nm) <= 60 and not any(k in low for k in _PDF_STOP):
|
| 426 |
+
pending = (pending + [nm])[-2:]
|
| 427 |
+
else:
|
| 428 |
+
pending = []
|
| 429 |
return out
|
| 430 |
|
| 431 |
|