Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions casparser/parsers/cams_detailed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -723,6 +758,7 @@ def parse(
parse_warnings.append(w)
header_buf = []
header_active = False
holder_name_lines_left = 0
continue

# --- Folio header ---
Expand Down Expand Up @@ -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")):
Expand All @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions casparser/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions schema/CASData.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@
"title": "Folio",
"type": "string"
},
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Name"
},
"schemes": {
"items": {
"$ref": "#/$defs/Scheme"
Expand Down
164 changes: 162 additions & 2 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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