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.
- {panini_parser-0.1.0 → panini_parser-0.2.0}/PKG-INFO +1 -1
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/__init__.py +11 -3
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/engine.py +35 -9
- panini_parser-0.2.0/panini/extract.py +383 -0
- panini_parser-0.2.0/panini/formats/csv-layouts.json +301 -0
- panini_parser-0.2.0/panini/formats/sms-patterns.json +57 -0
- panini_parser-0.2.0/panini/rules/entities/mcc-codes.json +200 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/PKG-INFO +1 -1
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/SOURCES.txt +6 -1
- {panini_parser-0.1.0 → panini_parser-0.2.0}/pyproject.toml +2 -2
- {panini_parser-0.1.0 → panini_parser-0.2.0}/tests/test_bundled_rules.py +12 -0
- panini_parser-0.2.0/tests/test_extract.py +92 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/MANIFEST.in +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/README.md +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/dates.py +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/archetypes.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/axis.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/bob.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/canara.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/categories.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/ifsc-codes.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/merchants.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/entities/vpa-handles.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/federal.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/generic.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/hdfc.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/icici.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/idfc.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/indusind.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/kotak.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/pnb.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/rbl.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/sbi.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/union.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/wallet.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/rules/yes.json +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini/tokenizer.py +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/dependency_links.txt +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/panini_parser.egg-info/top_level.txt +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/setup.cfg +0 -0
- {panini_parser-0.1.0 → panini_parser-0.2.0}/tests/test_conformance.py +0 -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.
|
|
11
|
-
__all__ = [
|
|
12
|
-
|
|
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] = {
|
|
405
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
+
}
|