From cf6bafc08f925bf349b6537b3a71cf15469f2ece Mon Sep 17 00:00:00 2001 From: Sandeep Somasekharan Date: Sun, 2 Aug 2026 10:12:43 +1000 Subject: [PATCH] feat(cams): extract folio holder name as folios[].name (#145) Parse folio holder's name from CAMS/KFintech detailed reports where available. --- CHANGELOG.md | 9 ++ README.md | 3 + casparser/parsers/cams_detailed.py | 52 +++++++++ casparser/types.py | 6 ++ schema/CASData.schema.json | 12 +++ tests/test_helpers.py | 164 ++++++++++++++++++++++++++++- 6 files changed, 244 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080a7c4..d4a0689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### New + +- **Folio holder name (CAMS/KFintech detailed).** `folios[].name` carries the + holder's name as printed in each folio header, so multi-investor statements + can associate each PAN with its holder instead of the statement addressee. + `null` on older templates that don't print a per-folio name. (#145) + ## 1.3.0 ### New diff --git a/README.md b/README.md index ae1174f..d9dcb9e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ Serialisation notes: folios: { folio: string, // "12345678 / 90" amc: string, + name: string | null, // folio holder's name as printed; + // null on older templates that omit it PAN: string | null, KYC: "OK" | "NOT OK" | null, PANKYC: "OK" | "NOT OK" | null, @@ -162,6 +164,7 @@ still returns, but the flagged scheme's data should not be trusted blindly. { "folio": "12345678 / 90", "amc": "HDFC Mutual Fund", + "name": "JOHN DOE", "PAN": "ABCDE1234F", "KYC": "OK", "PANKYC": "OK", diff --git a/casparser/parsers/cams_detailed.py b/casparser/parsers/cams_detailed.py index c356c69..1a44a7c 100644 --- a/casparser/parsers/cams_detailed.py +++ b/casparser/parsers/cams_detailed.py @@ -468,6 +468,32 @@ def _is_header_line(text: str) -> bool: ) +# Holder-name line: newer CAMS/KFin DETAILED templates print the folio +# holder's name on the line right after `Folio No:` (older templates +# jump straight to the scheme line). The case varies by template — the +# same statement prints "JOHN DOE" in some blocks and "John Doe" in +# others — so the guards are structural: letters-only with name +# punctuation (any digit or colon marks a date / amount / `KYC: OK` +# fragment), every word capitalized (rejects load/disclaimer prose), +# not a transaction-column header (follows the folio line across a page +# break), and not a scheme/registrar line (`_is_header_line`). +_HOLDER_NAME_CHARS_RE = re.compile(r"^[A-Z][A-Za-z .'&-]*$") + + +def _looks_like_holder_name(text: str) -> bool: + t = " ".join(text.split()) + words = t.split() + if not (2 <= len(words) <= 8 and len(t) <= 80): + return False + if not _HOLDER_NAME_CHARS_RE.match(t): + return False + if not all(w == "&" or w[0].isupper() for w in words): + return False + if sum(w in TXN_HEADER_LABELS for w in words) >= TXN_MIN_HITS: + return False + return not _is_header_line(t) + + def _expects_continuation(text: str) -> bool: """True if `text` leaves a marker value dangling onto the next line.""" if _TRAILING_MARKER_RE.search(text.strip()): @@ -689,6 +715,15 @@ def parse( header_buf: List[str] = [] header_active: bool = False + # Lines still eligible to be the current folio's holder-name line. + # Opened (=2) at each folio header whose folio has no name yet — the + # name sits on the very next line, with one line of tolerance for + # interleaved junk (page-break banners, watermark fragments). Any + # scheme-header evidence closes the window: old-format statements + # print no name at all, and the window must not creep into the + # scheme region and mistake later caps-only text for a name. + holder_name_lines_left: int = 0 + # Non-fatal data-quality warnings. Region anomalies (an unparseable # or abandoned header) are appended during the loop; the per-scheme # balance reconciliation extends the list afterwards. @@ -723,6 +758,7 @@ def parse( parse_warnings.append(w) header_buf = [] header_active = False + holder_name_lines_left = 0 continue # --- Folio header --- @@ -753,6 +789,9 @@ def parse( ) current_folio = folios[folio_key] current_scheme = None + # The folio line repeats for every scheme, so a name + # missed once (page break) is retried on the next one. + holder_name_lines_left = 2 if current_folio.name is None else 0 # The lines until this folio's first Opening Unit Balance # are its first scheme's header region. if header_active and (w := _abandoned_region_warning(header_buf, "folio boundary")): @@ -761,6 +800,19 @@ def parse( header_active = True continue + # --- Folio holder name (issue #145): observe — never consume — + # the first line(s) after a folio header. The line still + # flows into the scheme-header region buffer below, where + # it is inert (it carries no header markers). --- + if holder_name_lines_left and current_folio is not None: + holder_name_lines_left -= 1 + stripped = text.strip() + if _looks_like_holder_name(stripped): + current_folio.name = " ".join(stripped.split()) + holder_name_lines_left = 0 + elif _is_header_line(stripped) or OPEN_BAL_RE.search(stripped): + holder_name_lines_left = 0 + # --- Opening Unit Balance: closes the scheme-header region and # builds the scheme from the accumulated buffer. --- if m := OPEN_BAL_RE.search(text): diff --git a/casparser/types.py b/casparser/types.py index d9eea5c..60da26b 100644 --- a/casparser/types.py +++ b/casparser/types.py @@ -105,6 +105,12 @@ class Folio(BaseModel): folio: str amc: str + # First/primary holder's name as printed in the folio header. Newer + # CAMS/KFintech DETAILED templates print it on the line after + # `Folio No:`; older templates omit it entirely, so None means + # "not present in the statement" — fall back to investor_info.name + # only for single-investor statements. See issue #145. + name: Optional[str] = None PAN: Optional[str] = None KYC: Optional[str] = None PANKYC: Optional[str] = None diff --git a/schema/CASData.schema.json b/schema/CASData.schema.json index 8959a4a..29a04ff 100644 --- a/schema/CASData.schema.json +++ b/schema/CASData.schema.json @@ -69,6 +69,18 @@ "title": "Folio", "type": "string" }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Name" + }, "schemes": { "items": { "$ref": "#/$defs/Scheme" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 56e334f..15a02bf 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -94,8 +94,7 @@ def test_reversal(self): # A failed-SIP "payment not received" row carries negative units # but is a reversal of a provisional purchase, not a redemption. assert get_transaction_type( - "SIP Purchase151/Payment not received from investor Banker " - "Physical - Instalment No 1", + "SIP Purchase151/Payment not received from investor Banker Physical - Instalment No 1", Decimal("-1.365"), ) == (TransactionType.REVERSAL, None) @@ -464,3 +463,164 @@ def test_leaves_row_untouched_when_neither_sign_matches(self): _apply_balance_sign_fix(scheme) t = scheme.transactions[0] assert t.units == Decimal("50") + + +class TestHolderNameLine: + """Newer CAMS/KFin DETAILED templates print the folio holder's name + on the line after `Folio No:`; `_looks_like_holder_name` is the + guard that separates it from everything else that can occupy that + slot — scheme lines (old format), dates, loads, KYC fragments. (#145)""" + + def test_names_match(self): + from casparser.parsers.cams_detailed import _looks_like_holder_name + + assert _looks_like_holder_name("JOHN DOE") + assert _looks_like_holder_name("John Doe") # same template, other blocks + assert _looks_like_holder_name("JOHN MICHAEL DOE") + assert _looks_like_holder_name("DOE J M") + assert _looks_like_holder_name("J M DOE") + assert _looks_like_holder_name("MARY D'SOUZA") + assert _looks_like_holder_name("Mary D'Souza") + assert _looks_like_holder_name("JOHN DOE & JANE DOE") + assert _looks_like_holder_name(" JOHN DOE ") # stray spacing + + def test_non_names_rejected(self): + from casparser.parsers.cams_detailed import _looks_like_holder_name + + # old-format: scheme line directly follows the folio line + assert not _looks_like_holder_name( + "128TSGPG-Axis Long Term Equity Fund - Growth - " + "ISIN: INF846K01131(Advisor: ARN-12345) Registrar : CAMS" + ) + assert not _looks_like_holder_name("Folio No: 12345678 / 0 PAN: ABCDE1234F") + assert not _looks_like_holder_name("KYC: OK") + assert not _looks_like_holder_name("Opening Unit Balance: 0.000") + assert not _looks_like_holder_name("01-Jan-2021 To 31-Dec-2021") + assert not _looks_like_holder_name("Entry Load - NIL. Exit Load - NIL") + assert not _looks_like_holder_name("Nominee 1: JANE DOE") + assert not _looks_like_holder_name("KFINTECH") # RTA wrap line + assert not _looks_like_holder_name("ARN-28283)") # advisor wrap line + assert not _looks_like_holder_name( + "REGISTERED OFFICE MUMBAI INDIA CORPORATE PARK TOWER B FLOOR NINE UNIT FOUR" + ) # >8 words + assert not _looks_like_holder_name("TOTAL") # single word + assert not _looks_like_holder_name("") + # transaction-column header directly after a page break + assert not _looks_like_holder_name("Date Transaction Amount Units Price Unit") + # mixed-case prose: not every word capitalized + assert not _looks_like_holder_name("Units held as on date") + + +class TestFolioHolderNameParse: + """End-to-end name capture through `cams_detailed.parse`, on a + synthetic two-investor statement (new-format folios carry a name + line, old-format folios don't). (#145)""" + + @staticmethod + def _line(text: str, baseline: float): + from casparser.parsers.extract import Char, Line + + chars = [ + Char(text=ch, x0=i * 5.0, y0=baseline, x1=i * 5.0 + 5.0, y1=baseline + 10.0) + for i, ch in enumerate(text) + ] + return Line(page=1, baseline=baseline, chars=chars) + + def _parse(self, monkeypatch, text_lines): + import casparser.parsers.cams_detailed as mod + from casparser.parsers.extract import Page + from casparser.types import InvestorInfo + + lines = [self._line(t, 800.0 - 12.0 * i) for i, t in enumerate(text_lines)] + monkeypatch.setattr(mod, "extract_pages", lambda *a, **k: [Page(number=1, lines=lines)]) + monkeypatch.setattr( + mod, + "extract_cams_kfin_investor", + lambda *a, **k: InvestorInfo(name="JOHN DOE", email="", address="", mobile=""), + ) + return mod.parse("synthetic.pdf", "") + + SCHEME_LINE = ( + "128TSGPG-Axis Long Term Equity Fund - Growth - " + "ISIN: INF846K01131(Advisor: ARN-12345) Registrar : CAMS" + ) + + def test_new_format_names_per_folio(self, monkeypatch): + data = self._parse( + monkeypatch, + [ + "01-Jan-2021 To 31-Dec-2021", + "Axis Mutual Fund", + "Folio No: 11111111 / 0 PAN: AAAAA1111A KYC: OK PAN: OK", + "JOHN DOE", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + "Folio No: 22222222 / 0 PAN: BBBBB2222B KYC: OK PAN: OK", + "JANE ROE", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + ], + ) + by_folio = {f.folio: f for f in data.folios} + assert by_folio["11111111 / 0"].name == "JOHN DOE" + assert by_folio["11111111 / 0"].PAN == "AAAAA1111A" + assert by_folio["22222222 / 0"].name == "JANE ROE" + assert by_folio["22222222 / 0"].PAN == "BBBBB2222B" + + def test_old_format_has_no_name(self, monkeypatch): + # scheme line directly after the folio line — name stays None + data = self._parse( + monkeypatch, + [ + "01-Jan-2021 To 31-Dec-2021", + "Axis Mutual Fund", + "Folio No: 33333333 / 0 PAN: CCCCC3333C KYC: OK PAN: OK", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + ], + ) + assert data.folios[0].name is None + assert data.folios[0].PAN == "CCCCC3333C" + + def test_name_backfilled_from_later_folio_block(self, monkeypatch): + # First occurrence misses the name (e.g. page-break junk ate the + # window); the folio line repeats per scheme and the second + # block's name must backfill the same Folio object. + data = self._parse( + monkeypatch, + [ + "01-Jan-2021 To 31-Dec-2021", + "Axis Mutual Fund", + "Folio No: 44444444 / 0 PAN: DDDDD4444D KYC: OK PAN: OK", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + "Folio No: 44444444 / 0 PAN: DDDDD4444D KYC: OK PAN: OK", + "JOHN DOE", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + ], + ) + assert len(data.folios) == 1 + assert data.folios[0].name == "JOHN DOE" + + def test_nominee_not_mistaken_for_holder(self, monkeypatch): + # Nominee line right after the folio line (no holder name printed) + # must not be captured — it carries a header marker. + data = self._parse( + monkeypatch, + [ + "01-Jan-2021 To 31-Dec-2021", + "Axis Mutual Fund", + "Folio No: 55555555 / 0 PAN: EEEEE5555E KYC: OK PAN: OK", + "Nominee 1: JANE DOE Nominee 2: Nominee 3:", + self.SCHEME_LINE, + "Opening Unit Balance: 0.000", + "Closing Unit Balance: 0.000", + ], + ) + assert data.folios[0].name is None