diff --git a/README.md b/README.md index dfdc415..539e036 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ Serialisation notes: balance: decimal | null, // running unit balance type: string, // see transaction types below dividend_rate: decimal | null, // DIVIDEND_* rows only + gift_folio: string | null, // GIFT_IN/OUT only: counterparty folio }[], }[], }[], @@ -225,6 +226,7 @@ still returns, but the flagged scheme's data should not be trusted blindly. | `DIVIDEND_PAYOUT` / `DIVIDEND_REINVEST` | IDCW / dividend rows — these carry `dividend_rate` | | `STT_TAX` / `STAMP_DUTY_TAX` / `TDS_TAX`| Tax rows — `amount` only, no units | | `SEGREGATION` | Units allotted in a segregated (side-pocketed) portfolio | +| `GIFT_IN` / `GIFT_OUT` | Units gifted in / out via inter-folio transfer | | `REVERSAL` | Reversed / rejected transaction | | `MISC` / `UNKNOWN` | Anything that doesn't match the above | @@ -320,7 +322,12 @@ casparser /path/to/cas.pdf -p password -g -o pdf_parsed.csv a summary flag (`-s/--summary`) is passed as argument to the CLI. Otherwise, full transaction history is included in the export. If `-g` flag is present, two additional files '{basename}-gains-summary.csv', - '{basename}-gains-detailed.csv' are created with the capital-gains data. + '{basename}-gains-detailed.csv' are created with the capital-gains data. If the + statement contains inter-folio gift transfers, a '{basename}-gifts.csv' is also + written. Gifts are listed in a separate informational section and are **not** + included in the capital-gains figures — a gift is not a transfer for the donor, + and the recipient's cost basis carries over from the donor (not present in a + single CAS). This is a disclosure, not tax advice. 3. any other extension - The summary table is saved in the file. diff --git a/casparser/__init__.py b/casparser/__init__.py index 1735d0d..b106f27 100644 --- a/casparser/__init__.py +++ b/casparser/__init__.py @@ -9,4 +9,4 @@ "CapitalGainsReport", ] -__version__ = "1.1.0" +__version__ = "1.2.0" diff --git a/casparser/analysis/gains.py b/casparser/analysis/gains.py index a3251cf..6a7b31d 100644 --- a/casparser/analysis/gains.py +++ b/casparser/analysis/gains.py @@ -256,6 +256,44 @@ def stcg(self) -> Decimal: return Decimal(0.0) +@dataclass +class GiftEntry: + """An inter-folio gift transfer — informational only. + + Gifts are deliberately kept out of the capital-gains computation: + the donor side is not a transfer (Sec 47(iii) → no gain), and the + recipient side needs the *donor's* cost basis and holding period + (Sec 49(1) / 2(42A)) which do not exist in a single CAS. This record + exists purely to disclose the transfer. + """ + + fy: str + fund: Fund + direction: str # "IN" or "OUT" + date: date + units: Decimal + nav: Decimal + value: Decimal + counterparty_folio: str + + @classmethod + def from_transaction(cls, fund: Fund, txn: TransactionData) -> "GiftEntry": + dt = txn.date + if isinstance(dt, str): + dt = dateparse(dt).date() + direction = "IN" if txn.type == TransactionType.GIFT_IN else "OUT" + return cls( + fy=get_fin_year(dt), + fund=fund, + direction=direction, + date=dt, + units=txn.units, + nav=txn.nav, + value=txn.amount, + counterparty_folio=txn.gift_folio or "", + ) + + def get_fund_type(transactions: List[TransactionData]) -> FundType: """ Detect Fund Type. @@ -268,7 +306,9 @@ def get_fund_type(transactions: List[TransactionData]) -> FundType: """ valid = any( [ - x.units is not None and x.units < 0 and x.type != TransactionType.REVERSAL + x.units is not None + and x.units < 0 + and x.type not in (TransactionType.REVERSAL, TransactionType.GIFT_OUT) for x in transactions ] ) @@ -398,6 +438,7 @@ class CapitalGainsReport: def __init__(self, data: CASData): self._data: CASData = data self._gains: List[GainEntry] = [] + self._gifts: List[GiftEntry] = [] self.errors = [] self.invested_amount = Decimal(0.0) self.current_value = Decimal(0.0) @@ -407,9 +448,16 @@ def __init__(self, data: CASData): def gains(self) -> List[GainEntry]: return list(sorted(self._gains, key=lambda x: (x.fy, x.fund, x.sale_date))) + @property + def gifts(self) -> List[GiftEntry]: + return list(sorted(self._gifts, key=lambda x: (x.fy, x.fund, x.date))) + def has_gains(self) -> bool: return len(self.gains) > 0 + def has_gifts(self) -> bool: + return len(self._gifts) > 0 + def has_error(self) -> bool: return len(self.errors) > 0 @@ -418,6 +466,7 @@ def get_fy_list(self) -> List[str]: def process_data(self): self._gains = [] + self._gifts = [] for folio in self._data.folios: for scheme in folio.schemes: transactions = scheme.transactions @@ -427,6 +476,16 @@ def process_data(self): isin=scheme.isin, type=scheme.type, ) + # Disclose every gift transfer, regardless of direction. + # These are not part of the capital-gains computation. + gift_txns = [ + t + for t in transactions + if t.type in (TransactionType.GIFT_IN, TransactionType.GIFT_OUT) + ] + for txn in gift_txns: + self._gifts.append(GiftEntry.from_transaction(fund, txn)) + has_gift_in = any(t.type == TransactionType.GIFT_IN for t in transactions) if len(transactions) > 0: if scheme.open >= 0.01: raise IncompleteCASError( @@ -439,7 +498,24 @@ def process_data(self): self.current_value += scheme.valuation.value self._gains.extend(fifo.gains) except GainsError as exc: - self.errors.append((fund.name, str(exc))) + # A FIFO shortfall on a scheme that received gifted-in + # units means a later sale is consuming units whose + # cost basis lives in the *donor's* statement, not + # here. Don't surface the generic mismatch; explain it. + if has_gift_in: + self.errors.append( + ( + fund.name, + "Scheme received gifted-in units; capital " + "gains on their later sale require the " + "donor's cost basis and holding period " + "(Sec 49(1) / 2(42A)), which are not present " + "in this statement. Scheme excluded from " + "gains — see the Gift transactions section.", + ) + ) + else: + self.errors.append((fund.name, str(exc))) def get_summary(self): """Calculate capital gains summary""" @@ -511,6 +587,39 @@ def get_gains_csv_data(self) -> str: csv_data = csv_fp.read() return csv_data + def get_gifts_csv_data(self) -> str: + """Return the informational gift-transfer list as a csv string.""" + headers = [ + "FY", + "Fund", + "ISIN", + "Direction", + "Date", + "Units", + "NAV", + "Value", + "Counterparty Folio", + ] + with io.StringIO() as csv_fp: + writer = csv.writer(csv_fp) + writer.writerow(headers) + for gift in self.gifts: + writer.writerow( + [ + gift.fy, + gift.fund.name, + gift.fund.isin, + gift.direction, + gift.date, + gift.units, + gift.nav, + gift.value, + gift.counterparty_folio, + ] + ) + csv_fp.seek(0) + return csv_fp.read() + def generate_112a(self, fy) -> List[GainEntry112A]: fy_transactions = sorted( list( diff --git a/casparser/cli.py b/casparser/cli.py index 2dd2746..d8e1dbe 100644 --- a/casparser/cli.py +++ b/casparser/cli.py @@ -316,11 +316,51 @@ def print_summary(parsed_data: CASData, output_filename=None, include_zero_folio console.print(f"File saved : [bold]{output_filename}[/]") +def print_gifts(cg: CapitalGainsReport): + """Render the informational gift-transfer disclosure table.""" + table = Table(title="Gift transactions (informational — not in gains)", show_lines=True) + table.add_column("FY", no_wrap=True) + table.add_column("Fund") + table.add_column("Dir") + table.add_column("Date", no_wrap=True) + table.add_column("Units", justify="right") + table.add_column("Value", justify="right") + table.add_column("Counterparty Folio") + for gift in cg.gifts: + table.add_row( + gift.fy, + gift.fund.name, + gift.direction, + str(gift.date), + f"{gift.units}", + f"{formatINR(abs(gift.value))}" if gift.value is not None else "-", + gift.counterparty_folio or "-", + ) + console.print(table) + console.print( + "[bold yellow]Note:[/] Gifts are excluded from capital-gains figures. " + "A gift is not a transfer for the donor (Sec 47(iii)). For the recipient, " + "cost basis and holding period carry over from the donor (Sec 49(1) / " + "2(42A)) and are not available in this statement; the gift itself may be " + "taxable as income from other sources (Sec 56(2)(x)) if from a non-relative. " + "This is a disclosure, not tax advice." + ) + + def print_gains(parsed_data: CASData, output_file_path=None, gains_112a=""): cg = CapitalGainsReport(parsed_data) data = parsed_data.model_dump(by_alias=True) if not cg.has_gains(): console.print("[bold yellow]Warning:[/] No capital gains info found in CAS") + if cg.has_gifts(): + print_gifts(cg) + if isinstance(output_file_path, str): + base_path, ext = os.path.splitext(output_file_path) + if ext.lower().endswith("csv"): + fname = f"{base_path}-gifts.csv" + with open(fname, "w", newline="", encoding="utf-8") as fp: + fp.write(cg.get_gifts_csv_data()) + console.print(f"Gift transactions saved : [bold]{fname}[/]") return summary = cg.get_summary() @@ -379,6 +419,14 @@ def print_gains(parsed_data: CASData, output_file_path=None, gains_112a=""): with open(fname, "w", newline="", encoding="utf-8") as fp: fp.write(cg.get_gains_csv_data()) console.print(f"Detailed gains report saved : [bold]{fname}[/]") + if cg.has_gifts(): + fname = f"{base_path}-gifts.csv" + with open(fname, "w", newline="", encoding="utf-8") as fp: + fp.write(cg.get_gifts_csv_data()) + console.print(f"Gift transactions saved : [bold]{fname}[/]") + + if cg.has_gifts(): + print_gifts(cg) if cg.has_error(): console.print("[bold red]WARNING[/] Failed to calculate gains for the following funds.") diff --git a/casparser/enums.py b/casparser/enums.py index f659019..722704f 100644 --- a/casparser/enums.py +++ b/casparser/enums.py @@ -54,6 +54,8 @@ class TransactionType(str, AutoEnum): STAMP_DUTY_TAX = auto() TDS_TAX = auto() SEGREGATION = auto() + GIFT_IN = auto() + GIFT_OUT = auto() MISC = auto() UNKNOWN = auto() REVERSAL = auto() diff --git a/casparser/parsers/_classify.py b/casparser/parsers/_classify.py index fa3bab6..f92a055 100644 --- a/casparser/parsers/_classify.py +++ b/casparser/parsers/_classify.py @@ -37,6 +37,21 @@ ) REINVEST_RE = re.compile(r"reinvest", re.I) +# Counterparty folio embedded in a gift transfer description. Both RTAs +# name the other folio but punctuate differently — KFin uses a colon +# ("Folio No: 12345678901"), CAMS a dot ("Folio No.87654321") — so accept +# either separator. Used to link a donor's GIFT_OUT to the donee's GIFT_IN +# across two CAS files. +GIFT_FOLIO_RE = re.compile(r"Folio\s+No\s*[:.]\s*(\d+)", re.I) + + +def extract_gift_folio(description: str) -> Optional[str]: + """Return the counterparty folio number named in a gift description, + or None if absent.""" + if m := GIFT_FOLIO_RE.search(description or ""): + return m.group(1) + return None + def get_transaction_type( description: str, units: Optional[Decimal] @@ -65,7 +80,9 @@ def get_transaction_type( else: txn_type = TransactionType.MISC elif units > 0: - if "switch" in description: + if "gift" in description: + txn_type = TransactionType.GIFT_IN + elif "switch" in description: txn_type = ( TransactionType.SWITCH_IN_MERGER if "merger" in description @@ -83,7 +100,9 @@ def get_transaction_type( else: txn_type = TransactionType.PURCHASE elif units < 0: - if re.search( + if "gift" in description: + txn_type = TransactionType.GIFT_OUT + elif re.search( r"reversal|rejection|dishonoured|mismatch|insufficient\s+balance|" r"payment\s+not\s+received", description, diff --git a/casparser/parsers/cams_detailed.py b/casparser/parsers/cams_detailed.py index 34130c2..c356c69 100644 --- a/casparser/parsers/cams_detailed.py +++ b/casparser/parsers/cams_detailed.py @@ -28,7 +28,7 @@ from dateutil import parser as dateparse -from casparser.enums import CASFileType, FileType +from casparser.enums import CASFileType, FileType, TransactionType from casparser.types import ( CASData, Folio, @@ -38,7 +38,11 @@ TransactionData, ) -from ._classify import get_parsed_scheme_name, get_transaction_type +from ._classify import ( + extract_gift_folio, + get_parsed_scheme_name, + get_transaction_type, +) from ._investor import extract_cams_kfin_investor from ._isin import isin_search from .extract import Char, Line, extract_pages @@ -722,7 +726,18 @@ def parse( continue # --- Folio header --- - if "Folio No" in text and (m := FOLIO_LINE_RE.search(text)): + # A genuine folio header is its own line. A transaction row that + # *mentions* a folio number in its description — e.g. + # "Gifting of units-TO Folio No: 12345678901" — also contains + # "Folio No:" and matches FOLIO_LINE_RE, but always starts with a + # transaction date. Reject dated rows so gift transfers are not + # mistaken for folio boundaries (which dropped the row and, when a + # scheme's own folio number was redacted, the whole scheme). + if ( + "Folio No" in text + and not DATE_CELL_RE.match(text) + and (m := FOLIO_LINE_RE.search(text)) + ): # Preserve internal " / " for compatibility with production # parser output format (it keeps "12124203 / 63" style). folio_no = m.group(1).strip() @@ -850,6 +865,11 @@ def parse( if nav is None and amt is not None and units is not None and units != 0: nav = (amt / units).quantize(Decimal("0.0001")) txn_type, dividend_rate = get_transaction_type(desc, units) + gift_folio = ( + extract_gift_folio(desc) + if txn_type in (TransactionType.GIFT_IN, TransactionType.GIFT_OUT) + else None + ) if units is not None: current_scheme.close_calculated += units current_scheme.transactions.append( @@ -862,6 +882,7 @@ def parse( balance=bal, type=txn_type.name, dividend_rate=dividend_rate, + gift_folio=gift_folio, ) ) diff --git a/casparser/types.py b/casparser/types.py index 6a61da2..8e69e47 100644 --- a/casparser/types.py +++ b/casparser/types.py @@ -33,6 +33,11 @@ class TransactionData(BaseModel): balance: Union[Decimal, float, None] = None type: TransactionType dividend_rate: Union[Decimal, float, None] = None + # For GIFT_IN / GIFT_OUT transfers: the *counterparty* folio named in + # the description (the destination for a gift-out, the source for a + # gift-in). Lets a donor's statement be linked to the donee's across + # two CAS files. None for all other transaction types. + gift_folio: Optional[str] = None class SchemeValuation(BaseModel): diff --git a/schema/CASData.schema.json b/schema/CASData.schema.json index 278fc21..0884945 100644 --- a/schema/CASData.schema.json +++ b/schema/CASData.schema.json @@ -394,6 +394,18 @@ "default": null, "title": "Dividend Rate" }, + "gift_folio": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Gift Folio" + }, "nav": { "anyOf": [ { @@ -453,6 +465,8 @@ "STAMP_DUTY_TAX", "TDS_TAX", "SEGREGATION", + "GIFT_IN", + "GIFT_OUT", "MISC", "UNKNOWN", "REVERSAL" diff --git a/tests/test_gains.py b/tests/test_gains.py index ce6f66e..4b65ac6 100644 --- a/tests/test_gains.py +++ b/tests/test_gains.py @@ -9,15 +9,50 @@ Fund, FundType, GainEntry, + GiftEntry, MergedTransaction, _fy_needs_transfer_col, _transfer_flag, get_fund_type, ) from casparser.analysis.utils import CII, get_fin_year -from casparser.enums import TransactionType +from casparser.enums import CASFileType, FileType, TransactionType from casparser.exceptions import GainsError -from casparser.types import TransactionData +from casparser.types import ( + CASData, + Folio, + InvestorInfo, + Scheme, + SchemeValuation, + StatementPeriod, + TransactionData, +) + + +def _cas_with_transactions(transactions, *, scheme_type="EQUITY"): + """Build a minimal CASData wrapping a single scheme with the given + transactions (zero opening balance) for CapitalGainsReport tests.""" + scheme = Scheme( + scheme="Gift Test Fund - Direct Growth", + rta_code="G123", + rta="KFINTECH", + type=scheme_type, + isin="INF123456789", + open=Decimal("0"), + close=Decimal("0"), + close_calculated=Decimal("0"), + valuation=SchemeValuation(date="2026-03-31", nav=Decimal("10"), value=Decimal("0")), + transactions=transactions, + ) + folio = Folio(folio="12345", amc="Test Mutual Fund", schemes=[scheme]) + cas = CASData( + statement_period=StatementPeriod(from_="2021-04-01", to="2026-03-31"), + folios=[folio], + investor_info=InvestorInfo(name="X", email="x@x", address="x", mobile="x"), + cas_type=CASFileType.DETAILED, + file_type=FileType.KFINTECH, + ) + return CapitalGainsReport(cas) class TestGainsClass: @@ -85,6 +120,39 @@ def test_fund_type(self): ) assert get_fund_type(transactions) == FundType.EQUITY + def test_gift_out_is_not_a_redemption(self): + """An outgoing gift carries negative units but is not a taxable + transfer for the donor — it must not flag the fund as having + redemptions (FundType stays UNKNOWN) nor produce capital gains. (#134)""" + transactions = [ + TransactionData( + date="2022-01-01", + description="Purchase", + amount=Decimal("10000.00"), + units=Decimal("1000.000"), + nav=Decimal("10"), + balance=Decimal("1000.000"), + type=TransactionType.PURCHASE, + dividend_rate=None, + ), + TransactionData( + date="2025-11-14", + description="Gifting of units-TO Folio No: 12345678901", + amount=Decimal("-50000.00"), + units=Decimal("-1000.000"), + nav=Decimal("50"), + balance=Decimal("0.000"), + type=TransactionType.GIFT_OUT, + dividend_rate=None, + ), + ] + # No real redemption -> fund type cannot be inferred. + assert get_fund_type(transactions) == FundType.UNKNOWN + # FIFO must not record any disposal for the gift. + fund = Fund("Gift Fund", "123", "INF123456789", "EQUITY") + fifo = FIFOUnits(fund, transactions) + assert fifo.gains == [] + def test_merge_transaction(self): dt = date(2000, 1, 1) mt = MergedTransaction(dt) @@ -458,3 +526,100 @@ def test_112a_balance_includes_stamp_excludes_stt(self): assert row.expenditure == Decimal("0.00") # STT excluded assert row.deductions == Decimal("1001.00") assert row.balance == Decimal("999.00") + + +class TestGifts: + """Inter-folio gift transfers — disclosed informationally, never + folded into capital gains. (issue #134)""" + + def _purchase(self): + return TransactionData( + date="2022-01-01", + description="Purchase", + amount=Decimal("10000.00"), + units=Decimal("1000.000"), + nav=Decimal("10"), + balance=Decimal("1000.000"), + type=TransactionType.PURCHASE, + dividend_rate=None, + ) + + def _gift_out(self): + return TransactionData( + date="2025-11-14", + description="Gifting of units-TO Folio No: 12345678901", + amount=Decimal("-50000.00"), + units=Decimal("-1000.000"), + nav=Decimal("50"), + balance=Decimal("0.000"), + type=TransactionType.GIFT_OUT, + dividend_rate=None, + gift_folio="12345678901", + ) + + def test_gift_entry_from_transaction(self): + fund = Fund("F", "12345", "INF123456789", "EQUITY") + out = GiftEntry.from_transaction(fund, self._gift_out()) + assert out.direction == "OUT" + # counterparty_folio is carried from the parsed transaction field. + assert out.counterparty_folio == "12345678901" + assert out.date == date(2025, 11, 14) + assert out.fy == "FY2025-26" + # Incoming direction. + gift_in = TransactionData( + date="2025-11-14", + description="Gifting of units - From Folio No.87654321", + amount=Decimal("50000.00"), + units=Decimal("1000.000"), + nav=Decimal("50"), + balance=Decimal("1000.000"), + type=TransactionType.GIFT_IN, + dividend_rate=None, + gift_folio="87654321", + ) + ein = GiftEntry.from_transaction(fund, gift_in) + assert ein.direction == "IN" + assert ein.counterparty_folio == "87654321" + + def test_gift_out_disclosed_not_in_gains(self): + report = _cas_with_transactions([self._purchase(), self._gift_out()]) + assert report.has_gifts() is True + assert len(report.gifts) == 1 + assert report.gifts[0].direction == "OUT" + # A pure gift-out produces no realised gain and no error. + assert report.has_gains() is False + assert report.has_error() is False + assert "Direction" in report.get_gifts_csv_data() + + def test_recipient_resale_warns_and_excludes(self): + """Gifted-in units later redeemed: FIFO can't price them (donor's + basis is elsewhere), so the scheme is excluded with a specific, + gift-aware message — never the generic FIFO mismatch.""" + gift_in = TransactionData( + date="2024-01-01", + description="Gifting of units-FROM Folio No: 12345678901", + amount=Decimal("50000.00"), + units=Decimal("1000.000"), + nav=Decimal("50"), + balance=Decimal("1000.000"), + type=TransactionType.GIFT_IN, + dividend_rate=None, + ) + redemption = TransactionData( + date="2025-06-01", + description="Redemption", + amount=Decimal("-60000.00"), + units=Decimal("-1000.000"), + nav=Decimal("60"), + balance=Decimal("0.000"), + type=TransactionType.REDEMPTION, + dividend_rate=None, + ) + report = _cas_with_transactions([gift_in, redemption]) + assert report.has_error() is True + assert len(report.errors) == 1 + _, msg = report.errors[0] + assert "gifted-in units" in msg + assert "donor" in msg + # The gift itself is still disclosed. + assert report.has_gifts() is True diff --git a/tests/test_helpers.py b/tests/test_helpers.py index dd801dc..8adb91f 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -9,14 +9,25 @@ from casparser.enums import TransactionType from casparser.parsers._classify import ( + extract_gift_folio, get_parsed_scheme_name, get_transaction_type, ) from casparser.parsers._isin import isin_search -from casparser.parsers.cams_detailed import _reconcile_balances +from casparser.parsers.cams_detailed import ( + DATE_CELL_RE, + FOLIO_LINE_RE, + _reconcile_balances, +) from casparser.types import Scheme, SchemeValuation, TransactionData +def _is_folio_header(text: str) -> bool: + """Mirror of the folio-header guard in `cams_detailed.parse`: a real + folio header matches FOLIO_LINE_RE and is *not* a dated transaction row.""" + return bool("Folio No" in text and not DATE_CELL_RE.match(text) and FOLIO_LINE_RE.search(text)) + + def _scheme(open_bal, close_bal, rows): """Build a minimal Scheme from (units, balance) rows for reconciliation tests. `units`/`balance` may be None.""" @@ -88,6 +99,26 @@ def test_reversal(self): Decimal("-1.365"), ) == (TransactionType.REVERSAL, None) + def test_gifts(self): + # Inter-folio gift transfers. Donor side carries negative units, + # recipient side positive. Both CAMS/KFin punctuation variants + # ("-TO Folio No:" and " - To Folio No.") must classify on the + # "gift" keyword + units sign, not be mistaken for redemptions / + # purchases. (issue #134) + assert get_transaction_type( + "Gifting of units-TO Folio No: 12345678901", + Decimal("-4085.662"), + ) == (TransactionType.GIFT_OUT, None) + assert get_transaction_type( + "Gifting of units - To Folio No.87654321", + Decimal("-8224.686"), + ) == (TransactionType.GIFT_OUT, None) + # Recipient side — positive units, phrasing-agnostic. + assert get_transaction_type( + "Gifting of units-FROM Folio No: 12345678901", + Decimal("4085.662"), + ) == (TransactionType.GIFT_IN, None) + def test_dividends(self): assert get_transaction_type( "IDCW Reinvestment @ Rs.2.00 per unit", @@ -118,6 +149,46 @@ def test_dividends(self): ) == (TransactionType.DIVIDEND_REINVEST, Decimal("0.06")) +class TestFolioHeaderGuard: + """A gift transaction names the destination folio in its description, + so its row contains "Folio No:" and matches FOLIO_LINE_RE. It must not + be treated as a folio boundary — doing so dropped the row and, when a + scheme's own folio number was redacted/blank, the whole scheme. (#134)""" + + def test_genuine_folio_headers_match(self): + assert _is_folio_header("Folio No: 12345678901 PAN: ABCDE1234F KYC: OK PAN: OK") + assert _is_folio_header("Folio No: 12124203 / 63 KYC: OK") + + def test_gift_rows_are_not_folio_headers(self): + # Both punctuation variants seen in CAMS/KFin statements. + assert not _is_folio_header( + "14-Nov-2025 Gifting of units-TO Folio No: 12345678901 " + "(547,682.99) (4,085.662) 134.05 0.000" + ) + assert not _is_folio_header( + "20-Nov-2025 Gifting of units - To Folio No.87654321 " + "(776,558.40) (8,224.686) 94.4180 0.000" + ) + + +class TestExtractGiftFolio: + """The counterparty folio is pulled from a gift description for + cross-CAS linking. Both RTA punctuations must parse. (#134)""" + + def test_kfin_colon(self): + assert extract_gift_folio("Gifting of units-TO Folio No: 12345678901") == "12345678901" + + def test_cams_dot(self): + assert extract_gift_folio("Gifting of units - To Folio No.87654321") == "87654321" + + def test_incoming(self): + assert extract_gift_folio("Gifting of units-FROM Folio No: 99887766554") == "99887766554" + + def test_no_folio(self): + assert extract_gift_folio("Purchase") is None + assert extract_gift_folio("") is None + + class TestParsedSchemeName: def test_passthrough(self): assert (