Skip to content
Merged
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: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}[],
}[],
}[],
Expand Down Expand Up @@ -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 |

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


Expand Down
2 changes: 1 addition & 1 deletion casparser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
"CapitalGainsReport",
]

__version__ = "1.1.0"
__version__ = "1.2.0"
113 changes: 111 additions & 2 deletions casparser/analysis/gains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
]
)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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"""
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 48 additions & 0 deletions casparser/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.")
Expand Down
2 changes: 2 additions & 0 deletions casparser/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
23 changes: 21 additions & 2 deletions casparser/parsers/_classify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions casparser/parsers/cams_detailed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -862,6 +882,7 @@ def parse(
balance=bal,
type=txn_type.name,
dividend_rate=dividend_rate,
gift_folio=gift_folio,
)
)

Expand Down
Loading