sammy786 commited on
Commit
c2ddb81
·
1 Parent(s): 27bcd1f

Parser: multi-issuer support (HDFC/Axis/IDFC/Kotak), forex/fee fixes, city-split, brand aliases

Browse files
Files changed (2) hide show
  1. app/merchants.py +32 -0
  2. 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)\b",
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 desc.strip()[:40],
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 dm and amts:
249
- iso = _parse_date(dm.group(1))
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(dm.group(1), " ")
 
 
 
 
 
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{2,}", " ", desc).strip(" ,|")
268
- if not re.search(r"[A-Za-z]", desc): # no inline name -> use wrapped buffer
269
- desc = " ".join(pending).strip(" ,|")
 
 
 
 
 
 
 
 
 
 
 
 
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
- pending = []
 
 
 
 
 
 
 
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