panini-parser 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {panini_parser-0.1.0 → panini_parser-0.2.0}/PKG-INFO +1 -1
  2. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/__init__.py +11 -3
  3. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/engine.py +35 -9
  4. panini_parser-0.2.0/panini/extract.py +383 -0
  5. panini_parser-0.2.0/panini/formats/csv-layouts.json +301 -0
  6. panini_parser-0.2.0/panini/formats/sms-patterns.json +57 -0
  7. panini_parser-0.2.0/panini/rules/entities/mcc-codes.json +200 -0
  8. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/PKG-INFO +1 -1
  9. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/SOURCES.txt +6 -1
  10. {panini_parser-0.1.0 → panini_parser-0.2.0}/pyproject.toml +2 -2
  11. {panini_parser-0.1.0 → panini_parser-0.2.0}/tests/test_bundled_rules.py +12 -0
  12. panini_parser-0.2.0/tests/test_extract.py +92 -0
  13. {panini_parser-0.1.0 → panini_parser-0.2.0}/MANIFEST.in +0 -0
  14. {panini_parser-0.1.0 → panini_parser-0.2.0}/README.md +0 -0
  15. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/dates.py +0 -0
  16. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/archetypes.json +0 -0
  17. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/axis.json +0 -0
  18. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/bob.json +0 -0
  19. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/canara.json +0 -0
  20. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/categories.json +0 -0
  21. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/ifsc-codes.json +0 -0
  22. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/merchants.json +0 -0
  23. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/vpa-handles.json +0 -0
  24. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/federal.json +0 -0
  25. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/generic.json +0 -0
  26. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/hdfc.json +0 -0
  27. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/icici.json +0 -0
  28. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/idfc.json +0 -0
  29. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/indusind.json +0 -0
  30. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/kotak.json +0 -0
  31. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/pnb.json +0 -0
  32. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/rbl.json +0 -0
  33. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/sbi.json +0 -0
  34. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/union.json +0 -0
  35. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/wallet.json +0 -0
  36. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/yes.json +0 -0
  37. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/tokenizer.py +0 -0
  38. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/dependency_links.txt +0 -0
  39. {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/top_level.txt +0 -0
  40. {panini_parser-0.1.0 → panini_parser-0.2.0}/setup.cfg +0 -0
  41. {panini_parser-0.1.0 → panini_parser-0.2.0}/tests/test_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: panini-parser
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Deterministic, privacy-preserving parser for Indian bank narrations — Python port of @panini/rules-engine
5
5
  Author: People of Product
6
6
  License: Apache-2.0
@@ -6,7 +6,15 @@
6
6
  from .engine import Normalizer, parse_narration # noqa: F401
7
7
  from .dates import parse_indian_date, is_plausible_date # noqa: F401
8
8
  from .tokenizer import tokenize # noqa: F401
9
+ from .extract import ( # noqa: F401
10
+ extract_csv_statement,
11
+ extract_sms,
12
+ parse_statement_date,
13
+ )
9
14
 
10
- __version__ = "0.1.0"
11
- __all__ = ["Normalizer", "parse_narration", "parse_indian_date",
12
- "is_plausible_date", "tokenize"]
15
+ __version__ = "0.2.0"
16
+ __all__ = [
17
+ "Normalizer", "parse_narration", "parse_indian_date",
18
+ "is_plausible_date", "tokenize", "extract_csv_statement",
19
+ "extract_sms", "parse_statement_date",
20
+ ]
@@ -108,7 +108,7 @@ def preserve_acronym_case(value: str) -> str:
108
108
 
109
109
  _CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
110
110
  _SMS_SIGNAL = re.compile(r"\b(debited|credited|avl\s*bal|a/c\s*(?:no|xx|\*+))", re.IGNORECASE)
111
- _CURRENCY_PREFIX = re.compile(r"^(?:rs\.?|inr|₹)", re.IGNORECASE)
111
+ _CURRENCY_PREFIX = re.compile(r"^(?:rs\.?|inr|₹)\s*", re.IGNORECASE)
112
112
  _AMOUNT_BODY = re.compile(r"^\d+(?:\.\d{1,2})?$")
113
113
 
114
114
  _DIRECTION_TYPES = {"DEBIT", "CREDIT"}
@@ -401,8 +401,10 @@ _RULES_DIR = _default_rules_dir()
401
401
 
402
402
  def _load_entities(rules_dir: Path) -> dict[str, Any]:
403
403
  ent = rules_dir / "entities"
404
- out: dict[str, Any] = {"merchants": [], "vpa-handles": {}, "ifsc-codes": {}}
405
- for name in ("merchants", "vpa-handles", "ifsc-codes"):
404
+ out: dict[str, Any] = {
405
+ "merchants": [], "vpa-handles": {}, "ifsc-codes": {}, "mcc-codes": {},
406
+ }
407
+ for name in ("merchants", "vpa-handles", "ifsc-codes", "mcc-codes"):
406
408
  p = ent / f"{name}.json"
407
409
  if p.exists():
408
410
  data = json.loads(p.read_text())
@@ -410,6 +412,8 @@ def _load_entities(rules_dir: Path) -> dict[str, Any]:
410
412
  out[name] = data.get("merchants", [])
411
413
  elif name == "vpa-handles":
412
414
  out[name] = data.get("handles", {})
415
+ elif name == "mcc-codes":
416
+ out[name] = data.get("codes", {})
413
417
  else:
414
418
  out[name] = data.get("banks", {})
415
419
  return out
@@ -437,6 +441,21 @@ def _lookup_merchant(counterparty: Optional[str], merchants: list[dict]) -> Opti
437
441
 
438
442
 
439
443
  _IFSC_RE = re.compile(r"([A-Z]{4})0[A-Z0-9]{6}")
444
+ _MCC_TOKEN_RE = re.compile(r"\bMCC[-\s:]?(\d{4})\b", re.IGNORECASE)
445
+
446
+
447
+ def _extract_mcc_token(raw: Optional[str]) -> Optional[str]:
448
+ if not raw:
449
+ return None
450
+ m = _MCC_TOKEN_RE.search(raw)
451
+ return m.group(1) if m else None
452
+
453
+
454
+ def _lookup_mcc(value: Optional[str], codes: dict) -> Optional[dict]:
455
+ if not value:
456
+ return None
457
+ code = value.strip() if re.fullmatch(r"\d{4}", value.strip()) else _extract_mcc_token(value)
458
+ return codes.get(code) if code else None
440
459
 
441
460
 
442
461
  def _lookup_ifsc(value: Optional[str], banks: dict) -> Optional[dict]:
@@ -462,7 +481,7 @@ def parse_narration(input_: str | dict, *, enrich: bool = True,
462
481
  norm = Normalizer(rule_sets, categories)
463
482
  txn = norm.normalize(input_)
464
483
 
465
- merchant_entry = vpa_info = ifsc_info = None
484
+ merchant_entry = vpa_info = ifsc_info = mcc_info = None
466
485
  if enrich:
467
486
  entities = _load_entities(rd)
468
487
  merchant_entry = _lookup_merchant(txn.counterparty, entities["merchants"])
@@ -470,13 +489,17 @@ def parse_narration(input_: str | dict, *, enrich: bool = True,
470
489
  vpa_info = entities["vpa-handles"].get(txn.vpa_handle)
471
490
  ifsc_src = txn.metadata.get("bank_code") or txn.raw
472
491
  ifsc_info = _lookup_ifsc(ifsc_src, entities["ifsc-codes"])
492
+ mcc_token = txn.metadata.get("mcc") or txn.raw
493
+ mcc_info = _lookup_mcc(mcc_token, entities["mcc-codes"])
494
+ if mcc_info is None and (merchant_entry or {}).get("mcc"):
495
+ mcc_info = _lookup_mcc(merchant_entry["mcc"], entities["mcc-codes"])
473
496
 
474
497
  merchant = (merchant_entry or {}).get("canonical") or txn.counterparty
475
- category = (
476
- merchant_entry["category"]
477
- if txn.category == "Uncategorized" and merchant_entry
478
- else txn.category
479
- )
498
+ category = txn.category
499
+ if txn.category == "Uncategorized" and merchant_entry:
500
+ category = merchant_entry["category"]
501
+ elif txn.category == "Uncategorized" and mcc_info:
502
+ category = mcc_info["category"]
480
503
  return {
481
504
  "channel": txn.channel,
482
505
  "type": txn.type,
@@ -494,4 +517,7 @@ def parse_narration(input_: str | dict, *, enrich: bool = True,
494
517
  "vpa_app": (vpa_info or {}).get("app"),
495
518
  "vpa_bank": (vpa_info or {}).get("bank"),
496
519
  "ifsc_bank": (ifsc_info or {}).get("name"),
520
+ "mcc": (_extract_mcc_token(txn.raw) if enrich else None)
521
+ or (merchant_entry or {}).get("mcc"),
522
+ "mcc_description": (mcc_info or {}).get("description"),
497
523
  }
@@ -0,0 +1,383 @@
1
+ # Copyright 2026 Kapil — Project Panini Contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Statement extraction — Python port of @paniniapi/extract (CSV + SMS
4
+ tiers). Shares the same formats JSONs and corpus fixtures as the
5
+ TypeScript package; PDF/xlsx/image tiers are seams upstream and
6
+ intentionally not ported.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import io
13
+ import json
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from .dates import parse_indian_date
20
+
21
+ # ── format resolution ────────────────────────────────────────────────────────
22
+
23
+ def _formats_dir() -> Path:
24
+ env = os.environ.get("PANINI_FORMATS_DIR")
25
+ if env:
26
+ return Path(env)
27
+ bundled = Path(__file__).resolve().parent / "formats"
28
+ if (bundled / "csv-layouts.json").exists():
29
+ return bundled
30
+ return (
31
+ Path(__file__).resolve().parents[2] / "packages" / "extract" / "formats"
32
+ )
33
+
34
+
35
+ def _load(name: str, formats_dir: Optional[Path]) -> dict[str, Any]:
36
+ return json.loads(((formats_dir or _formats_dir()) / name).read_text())
37
+
38
+
39
+ # ── dates & amounts ─────────────────────────────────────────────────────────
40
+
41
+ _MONTHS = {
42
+ "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
43
+ "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7,
44
+ "july": 7, "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9,
45
+ "oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12,
46
+ "december": 12,
47
+ }
48
+
49
+ _NAMED_DATE = re.compile(r"^(\d{1,2})[-/. ]([A-Za-z]{3,9})[-/. ](\d{2,4})$")
50
+
51
+
52
+ def parse_statement_date(value: str) -> Optional[str]:
53
+ direct = parse_indian_date(value)
54
+ if direct:
55
+ return direct
56
+ m = _NAMED_DATE.match(value.strip())
57
+ if not m:
58
+ return None
59
+ month = _MONTHS.get(m.group(2).lower())
60
+ if month is None:
61
+ return None
62
+ return parse_indian_date(f"{int(m.group(1)):02d}-{month:02d}-{m.group(3)}")
63
+
64
+
65
+ _AMOUNT_BODY = re.compile(r"^-?\d+(?:\.\d{1,2})?$")
66
+ _CURRENCY_PREFIX = re.compile(r"^(?:rs\.?|inr|₹)\s*", re.IGNORECASE)
67
+
68
+
69
+ def _parse_paise(value: str) -> Optional[int]:
70
+ stripped = _CURRENCY_PREFIX.sub("", value.strip()).replace(",", "")
71
+ if not _AMOUNT_BODY.match(stripped):
72
+ return None
73
+ return round(float(stripped) * 100)
74
+
75
+
76
+ def _js_regex(pattern: str) -> re.Pattern[str]:
77
+ """JS named groups `(?<name>…)` → Python `(?P<name>…)`."""
78
+ return re.compile(re.sub(r"\(\?<([A-Za-z])", r"(?P<\1", pattern), re.IGNORECASE)
79
+
80
+
81
+ _ACCOUNT_RE = re.compile(
82
+ r"(?:a/c|account)\s*(?:no\.?|number|#)?\s*[:-]?\s*"
83
+ r"(?:x{2,}|\*{2,}|\d{2,}[x*]{2,})?(\d{3,6})\b",
84
+ re.IGNORECASE,
85
+ )
86
+
87
+ _DR_CR_SUFFIX = re.compile(r"\b(dr|cr|debit|credit)\b\.?\s*$", re.IGNORECASE)
88
+
89
+
90
+ def _find_account_mask(text: str) -> Optional[str]:
91
+ m = _ACCOUNT_RE.search(text)
92
+ return m.group(1) if m else None
93
+
94
+
95
+ def _direction_from_text(value: str) -> Optional[str]:
96
+ upper = value.upper()
97
+ if re.search(r"\b(DR|DEBIT|WITHDRAWAL|WDL)\b", upper):
98
+ return "DEBIT"
99
+ if re.search(r"\b(CR|CREDIT|DEPOSIT|DEP)\b", upper):
100
+ return "CREDIT"
101
+ return None
102
+
103
+
104
+ # ── CSV parsing ──────────────────────────────────────────────────────────────
105
+
106
+ def parse_csv(text: str) -> list[list[str]]:
107
+ """RFC-4180 rows via the stdlib reader; a leading BOM is stripped."""
108
+ reader = csv.reader(io.StringIO(text.lstrip("")))
109
+ return [row for row in reader]
110
+
111
+
112
+ def _norm_cell(value: str) -> str:
113
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9]+", " ", value.lower())).strip()
114
+
115
+
116
+ def detect_table(
117
+ rows: list[list[str]], layouts_file: dict[str, Any],
118
+ bank_hint: Optional[str] = None,
119
+ ) -> Optional[dict[str, Any]]:
120
+ max_scan = min(int(layouts_file.get("maxHeaderScanRows", 15)), len(rows))
121
+ norm_rows = [[_norm_cell(c) for c in row] for row in rows]
122
+
123
+ layouts = list(layouts_file.get("layouts", []))
124
+ if bank_hint:
125
+ hint = bank_hint.upper()
126
+ layouts.sort(
127
+ key=lambda l: (0 if l["bank"] == hint else 2 if l["bank"] == "GENERIC" else 1)
128
+ )
129
+
130
+ for layout in layouts:
131
+ required = set(layout.get("requiredHeaders", []))
132
+ for i in range(max_scan):
133
+ cells = norm_rows[i]
134
+ if required.issubset(set(cells)):
135
+ columns: dict[str, int] = {}
136
+ for field, candidates in layout.get("columns", {}).items():
137
+ for idx, cell in enumerate(cells):
138
+ if cell in candidates:
139
+ columns[field] = idx
140
+ break
141
+ if "date" in columns:
142
+ return {"layout": layout, "headerRowIndex": i, "columns": columns}
143
+
144
+ # Heuristic fallback: canonical keyword coverage with a date column.
145
+ generic = next(
146
+ (l for l in layouts_file.get("layouts", []) if l["id"] == "generic_statement"),
147
+ None,
148
+ )
149
+ keyword_fields: dict[str, list[str]] = generic["columns"] if generic else {}
150
+ best: Optional[dict[str, Any]] = None
151
+ best_score = 0
152
+ for i in range(max_scan):
153
+ cells = norm_rows[i]
154
+ columns = {}
155
+ score = 0
156
+ for field, candidates in keyword_fields.items():
157
+ for idx, cell in enumerate(cells):
158
+ if cell in candidates:
159
+ columns[field] = idx
160
+ score += 1
161
+ break
162
+ if (
163
+ score > best_score
164
+ and "date" in columns
165
+ and any(k in columns for k in ("narration", "debit", "amount"))
166
+ ):
167
+ best_score = score
168
+ best = {"layout": None, "headerRowIndex": i, "columns": columns}
169
+ return best
170
+
171
+
172
+ def map_row(cells: list[str], columns: dict[str, int]) -> dict[str, Any]:
173
+ warnings: list[str] = []
174
+
175
+ def cell(field: str) -> Optional[str]:
176
+ idx = columns.get(field)
177
+ if idx is None or idx >= len(cells) or cells[idx].strip() == "":
178
+ return None
179
+ return cells[idx].strip()
180
+
181
+ date = None
182
+ raw_date = cell("date")
183
+ if raw_date:
184
+ date = parse_statement_date(raw_date)
185
+ if not date:
186
+ warnings.append(f"unparseable date '{raw_date}'")
187
+
188
+ amount_paise: Optional[int] = None
189
+ direction: Optional[str] = None
190
+
191
+ debit_raw, credit_raw = cell("debit"), cell("credit")
192
+ amount_raw, direction_raw = cell("amount"), cell("direction")
193
+
194
+ if debit_raw is not None:
195
+ amount_paise = _parse_paise(debit_raw)
196
+ direction = "DEBIT"
197
+ if amount_paise is None:
198
+ warnings.append(f"unparseable debit '{debit_raw}'")
199
+ if credit_raw is not None:
200
+ credit = _parse_paise(credit_raw)
201
+ if credit is None:
202
+ warnings.append(f"unparseable credit '{credit_raw}'")
203
+ elif amount_paise is None:
204
+ amount_paise = credit
205
+ direction = "CREDIT"
206
+ else:
207
+ warnings.append("row carries both debit and credit; debit kept")
208
+ if amount_paise is None and amount_raw is not None:
209
+ suffix = _DR_CR_SUFFIX.search(amount_raw)
210
+ bare = _DR_CR_SUFFIX.sub("", amount_raw).strip() if suffix else amount_raw
211
+ parsed = _parse_paise(bare)
212
+ if parsed is None:
213
+ warnings.append(f"unparseable amount '{amount_raw}'")
214
+ else:
215
+ amount_paise = parsed
216
+ direction = _direction_from_text(suffix.group(1)) if suffix else direction
217
+ if direction is None and direction_raw is not None:
218
+ direction = _direction_from_text(direction_raw)
219
+ if direction is None and amount_paise is not None and amount_raw is not None:
220
+ if not _DR_CR_SUFFIX.search(amount_raw) and amount_raw.strip().startswith("-"):
221
+ direction = "DEBIT"
222
+
223
+ balance_paise = None
224
+ balance_raw = cell("balance")
225
+ if balance_raw is not None:
226
+ bal_suffix = _DR_CR_SUFFIX.search(balance_raw)
227
+ stripped = _DR_CR_SUFFIX.sub("", balance_raw).strip()
228
+ balance_paise = _parse_paise(stripped)
229
+ if balance_paise is not None and bal_suffix and _direction_from_text(bal_suffix.group(1)) == "DEBIT":
230
+ balance_paise = -balance_paise
231
+ if balance_paise is None:
232
+ warnings.append(f"unparseable balance '{balance_raw}'")
233
+
234
+ return {
235
+ "date": date,
236
+ "narration": cell("narration") or "",
237
+ "refNo": cell("refNo"),
238
+ "direction": direction,
239
+ "amountPaise": amount_paise,
240
+ "balancePaise": balance_paise,
241
+ "raw": " ".join(cells).strip(),
242
+ "warnings": warnings,
243
+ }
244
+
245
+
246
+ def extract_csv_statement(
247
+ text: str,
248
+ formats_dir: Optional[Path] = None,
249
+ bank_hint: Optional[str] = None,
250
+ ) -> dict[str, Any]:
251
+ layouts_file = _load("csv-layouts.json", formats_dir)
252
+ rows = parse_csv(text)
253
+ warnings: list[str] = []
254
+ max_scan = int(layouts_file.get("maxHeaderScanRows", 15))
255
+ preamble = " ".join(" ".join(r) for r in rows[:max_scan])
256
+ account_mask = _find_account_mask(preamble)
257
+
258
+ table = detect_table(rows, layouts_file, bank_hint)
259
+ if table is None:
260
+ return {
261
+ "format": "csv",
262
+ "bank": bank_hint,
263
+ "layoutId": None,
264
+ "accountMask": account_mask,
265
+ "transactions": [],
266
+ "warnings": ["no statement table detected in input"],
267
+ "metadata": {},
268
+ }
269
+
270
+ columns = table["columns"]
271
+ header_width = len(rows[table["headerRowIndex"]])
272
+ transactions = []
273
+ for i in range(table["headerRowIndex"] + 1, len(rows)):
274
+ cells = rows[i]
275
+ if not cells or all(c.strip() == "" for c in cells):
276
+ continue
277
+ date_idx = columns.get("date")
278
+ date_cell = cells[date_idx].strip() if date_idx is not None and date_idx < len(cells) else None
279
+ if not date_cell or not parse_statement_date(date_cell) or len(cells) > header_width + 2:
280
+ preview = " ".join(cells).strip()
281
+ if preview:
282
+ warnings.append(f"skipped non-transaction row {i + 1}: '{preview[:60]}'")
283
+ continue
284
+ transactions.append(map_row(cells, columns))
285
+
286
+ if not transactions:
287
+ warnings.append("header detected but no transaction rows parsed")
288
+
289
+ layout = table["layout"]
290
+ return {
291
+ "format": "csv",
292
+ "bank": layout["bank"] if layout else None,
293
+ "layoutId": layout["id"] if layout else "generic_heuristic",
294
+ "accountMask": account_mask,
295
+ "transactions": transactions,
296
+ "warnings": warnings,
297
+ "metadata": {},
298
+ }
299
+
300
+
301
+ # ── SMS ─────────────────────────────────────────────────────────────────────
302
+
303
+ def extract_sms(
304
+ body: str,
305
+ formats_dir: Optional[Path] = None,
306
+ bank_hint: Optional[str] = None,
307
+ ) -> dict[str, Any]:
308
+ file = _load("sms-patterns.json", formats_dir)
309
+ warnings: list[str] = []
310
+ text = body.strip()
311
+
312
+ direction: Optional[str] = None
313
+ amount_paise: Optional[int] = None
314
+ account_mask = _find_account_mask(text)
315
+ matched = None
316
+
317
+ for pattern in file.get("patterns", []):
318
+ if bank_hint and pattern.get("bank") not in ("GENERIC", bank_hint.upper()):
319
+ continue
320
+ try:
321
+ m = _js_regex(pattern["regex"]).search(text)
322
+ except re.error:
323
+ continue
324
+ if not m:
325
+ continue
326
+ matched = pattern["id"]
327
+ if pattern.get("direction") in ("DEBIT", "CREDIT"):
328
+ direction = pattern["direction"]
329
+ amount = m.groupdict().get("amount")
330
+ if amount:
331
+ amount_paise = _parse_paise(amount)
332
+ mask = m.groupdict().get("accountMask")
333
+ if not account_mask and mask:
334
+ account_mask = mask
335
+ break
336
+
337
+ balance_paise = None
338
+ bal_re = file.get("balanceRegex")
339
+ bal_match = _js_regex(bal_re).search(text) if bal_re else None
340
+ if bal_match:
341
+ raw = bal_match.groupdict().get("balance") or bal_match.group(1)
342
+ if raw:
343
+ balance_paise = _parse_paise(raw)
344
+
345
+ date = None
346
+ date_re = file.get("dateRegex")
347
+ date_match = _js_regex(date_re).search(text) if date_re else None
348
+ if date_match:
349
+ raw = date_match.groupdict().get("date") or date_match.group(1)
350
+ if raw:
351
+ date = parse_statement_date(raw)
352
+ if not date:
353
+ warnings.append(f"unparseable sms date '{raw}'")
354
+
355
+ if not matched:
356
+ warnings.append("no sms pattern matched; narration passed through for grammar parsing")
357
+
358
+ transactions = (
359
+ []
360
+ if amount_paise is None and not matched
361
+ else [
362
+ {
363
+ "date": date,
364
+ "narration": text,
365
+ "refNo": None,
366
+ "direction": direction,
367
+ "amountPaise": amount_paise,
368
+ "balancePaise": balance_paise,
369
+ "raw": text,
370
+ "warnings": [] if matched else ["unmatched sms pattern"],
371
+ }
372
+ ]
373
+ )
374
+
375
+ return {
376
+ "format": "sms",
377
+ "bank": bank_hint,
378
+ "layoutId": matched,
379
+ "accountMask": account_mask,
380
+ "transactions": transactions,
381
+ "warnings": warnings,
382
+ "metadata": {},
383
+ }