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
223 changes: 198 additions & 25 deletions casparser/analysis/gains.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from collections import deque
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import List
from decimal import ROUND_HALF_UP, Decimal
from typing import List, Optional

from dateutil.parser import parse as dateparse
from dateutil.relativedelta import relativedelta
Expand Down Expand Up @@ -41,9 +41,22 @@
# side of this date each *transfer* (sale) falls on.
LTCG_REGIME_CUTOFF = date(2024, 7, 23)

# Schedule 112A column 1b only exists on the AY 2025-26 (FY 2024-25) utility
# and later. We emit it when the report's FY starts in 2024 or later.
TRANSFER_COL_FROM_FY_START_YEAR = 2024
# Schedule 112A column 1b ("Share/Unit Transferred") exists only on the
# AY 2025-26 (FY 2024-25) utility — the single financial year straddling the
# 23-Jul-2024 LTCG-rate change, where transfers before/after the cutoff are
# taxed differently. FY2025-26 onward is a single regime again, so the column
# is gone.
TRANSFER_COL_FY_START_YEAR = 2024
# FY2025-26 onward the ITR utility accepts a single consolidated row for all
# after-31-Jan-2018 acquisitions instead of per-scrip rows.
CONSOLIDATED_AE_FROM_FY_START_YEAR = 2025
CONSOLIDATED_AE_ISIN = "INNOTREQUIRD"
CONSOLIDATED_AE_NAME = "CONSOLIDATED"


def _fy_start_year(fy: str) -> Optional[int]:
m = re.match(r"FY(\d{4})", fy or "")
return int(m.group(1)) if m else None


def _transfer_flag(sale_date: date) -> str:
Expand All @@ -53,10 +66,62 @@ def _transfer_flag(sale_date: date) -> str:


def _fy_needs_transfer_col(fy: str) -> bool:
"""True if `fy` (e.g. ``FY2024-25``) is FY2024-25 or later, i.e. the
Schedule 112A column 1b applies."""
m = re.match(r"FY(\d{4})", fy or "")
return bool(m) and int(m.group(1)) >= TRANSFER_COL_FROM_FY_START_YEAR
"""True only for FY2024-25 — the one year the Schedule 112A column 1b
applies (the 23-Jul-2024 rate split)."""
return _fy_start_year(fy) == TRANSFER_COL_FY_START_YEAR


def _fy_consolidates_ae(fy: str) -> bool:
"""True for FY2025-26 onward, where the ITR accepts a single consolidated
row for all after-31-Jan-2018 acquisitions."""
y = _fy_start_year(fy)
return y is not None and y >= CONSOLIDATED_AE_FROM_FY_START_YEAR


def _rupees(x: Decimal) -> str:
"""Whole-rupee integer string — the ITR Schedule 112A utility rejects
decimals in the amount fields."""
return str(int(Decimal(str(x)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)))


# Schedule CG "Section F" (and the CAMS/KFin desktop gains statements) split
# the year's realised gains into the five advance-tax installment windows, by
# date of transfer — this is what drives the 234C interest calc. The windows
# are uneven and the last is a 16-day sliver; boundaries below are inclusive of
# the closing date (15 Jun / 15 Sep / 15 Dec / 15 Mar).
QUARTER_LABELS = (
"Upto 15/6",
"16/6 to 15/9",
"16/9 to 15/12",
"16/12 to 15/3",
"16/3 to 31/3",
)

# The four gain categories casparser can distinguish for the quarterly split.
# casparser knows equity vs debt (fund type) and LTCG vs STCG (holding period);
# it cannot know the taxpayer's slab, so debt is left as one "applicable-rate"
# bucket rather than split by rate.
QUARTERLY_CATEGORIES = (
"Equity LTCG",
"Equity STCG",
"Debt LTCG",
"Debt STCG",
)


def _quarter_index(d: date) -> int:
"""Index (0-4) of the ITR advance-tax installment window a transfer on
`d` falls in, in fiscal-year order (Apr-Mar)."""
md = (d.month, d.day)
if d.month <= 3: # Jan-Mar — the tail of the financial year
return 4 if md >= (3, 16) else 3
if md <= (6, 15):
return 0
if md <= (9, 15):
return 1
if md <= (12, 15):
return 2
return 3 # 16 Dec - 31 Dec


@dataclass
Expand Down Expand Up @@ -105,6 +170,36 @@ def balance(self):
return self.sale_value - self.deductions


def _consolidate_ae_112a(rows: List[GainEntry112A]) -> List[GainEntry112A]:
"""Collapse all after-31-Jan-2018 (AE) rows into a single consolidated
row, keeping grandfathered (BE) rows itemised.

FY2025-26 onward the ITR Schedule 112A utility accepts one line for all
after-2018 acquisitions — Name ``CONSOLIDATED``, ISIN ``INNOTREQUIRD``,
quantity 0, with only the Full Value (6) and Cost (8) totals. BE rows
stay per-scrip because each needs its own 31-Jan-2018 FMV.
"""
ae = [r for r in rows if r.acquired != "BE"]
if not ae:
return rows
be = [r for r in rows if r.acquired == "BE"]
merged = GainEntry112A(
acquired="AE",
transferred="AE",
isin=CONSOLIDATED_AE_ISIN,
name=CONSOLIDATED_AE_NAME,
units=Decimal(0),
sale_nav=Decimal(0),
sale_value=sum((r.sale_value for r in ae), Decimal(0)),
purchase_value=sum((r.purchase_value for r in ae), Decimal(0)),
fmv_nav=Decimal(0),
fmv=Decimal(0),
stt=sum((r.stt for r in ae), Decimal(0)),
stamp_duty=sum((r.stamp_duty for r in ae), Decimal(0)),
)
return be + [merged]


@dataclass
class MergedTransaction:
"""Represent net transaction on a given date"""
Expand Down Expand Up @@ -256,6 +351,24 @@ def stcg(self) -> Decimal:
return Decimal(0.0)


def _taxable_112a(txn: "GainEntry") -> Decimal:
"""Per-lot taxable equity LTCG matching Schedule 112A col-14 (balance),
so the quarterly split reconciles with :meth:`generate_112a`.

Mirrors ``GainEntry112A.balance`` at the transaction level: the
grandfathering substitution (min of FMV & sale value) applies only to
lots acquired on/before 31-Jan-2018, and buy-side stamp duty is part of
the cost of acquisition (s.55). Because 112A consolidation is linear
(sum-then-subtract), summing this over a fund's lots equals that fund's
consolidated 112A-row balance.
"""
consideration = (
min(txn.fmv, txn.sale_value) if txn.purchase_date <= date(2018, 1, 31) else Decimal(0)
)
actual_coa = max(txn.purchase_value, consideration) + txn.stamp_duty
return txn.sale_value - actual_coa


@dataclass
class GiftEntry:
"""An inter-folio gift transfer — informational only.
Expand Down Expand Up @@ -684,6 +797,8 @@ def generate_112a(self, fy) -> List[GainEntry112A]:
ce.sale_nav = Decimal(round(txn.sale_value / txn.units, 3))
rows.extend(entries)
rows.extend(consolidated.values())
if _fy_consolidates_ae(fy):
rows = _consolidate_ae_112a(rows)
return rows

def generate_112a_csv_data(self, fy):
Expand Down Expand Up @@ -715,25 +830,83 @@ def generate_112a_csv_data(self, fy):
writer.writerow(headers)

for row in self.generate_112a(fy):
values = [
row.acquired,
row.isin,
row.name,
str(row.units),
str(row.sale_nav),
str(row.sale_value),
str(row.actual_coa),
str(row.purchase_value),
str(row.consideration_value),
str(row.fmv_nav),
str(row.fmv),
str(row.expenditure),
str(row.deductions),
str(row.balance),
]
if row.acquired == "AE":
# After-31-Jan-2018: the utility takes Full Value (6), Cost
# of acquisition (8) and Expenditure (12); the grandfathering
# cols 9/10/11 are N/A. We also fill the derived cols the
# utility computes (7 = higher of 8/9 = 8 here, 13 = 7+12,
# 14 = 6-13) so the CSV is self-consistent rather than
# relying on the import to recompute. Buy-side stamp duty is
# part of the cost of acquisition (s.55) → folded into 8;
# STT is not deductible (s.48) → excluded.
sale = int(_rupees(row.sale_value))
cost = int(_rupees(row.purchase_value + row.stamp_duty))
values = [
row.acquired,
row.isin,
row.name,
"0", # 4 No. of units (not used for AE)
"0", # 5 Sale-price per unit (not used for AE)
str(sale), # 6 Full Value of Consideration (gross)
str(cost), # 7 Cost w/o indexation = higher of 8, 9
str(cost), # 8 Cost of acquisition (incl. stamp)
"0", # 9 N/A (grandfathering)
"0", # 10 N/A
"0", # 11 N/A
"0", # 12 Expenditure (STT not deductible)
str(cost), # 13 Total deductions = 7 + 12
str(sale - cost), # 14 Balance = 6 - 13
]
else:
# BE (grandfathered) — itemised, per-scrip FMV. Rupee amounts
# rounded to whole rupees; units / per-unit NAVs kept exact.
values = [
row.acquired,
row.isin,
row.name,
str(row.units),
str(row.sale_nav),
_rupees(row.sale_value),
_rupees(row.actual_coa),
_rupees(row.purchase_value),
_rupees(row.consideration_value),
str(row.fmv_nav),
_rupees(row.fmv),
_rupees(row.expenditure),
_rupees(row.deductions),
_rupees(row.balance),
]
if with_transfer_col:
values.insert(1, row.transferred)
writer.writerow(values)
csv_fp.seek(0)
csv_data = csv_fp.read()
return csv_data

def quarterly_gains(self, fy) -> "dict[str, List[Decimal]]":
"""Realised taxable gains for `fy` split into the five ITR advance-tax
installment windows (Schedule CG, Section F) by date of transfer.

Returns an ordered mapping of category (see ``QUARTERLY_CATEGORIES``)
to a five-element list of :class:`~decimal.Decimal` amounts aligned
with ``QUARTER_LABELS``. The **Equity LTCG** row uses the same taxable
measure as :meth:`generate_112a`, so its total reconciles with the
112A report exactly (grandfathering and stamp duty handled per-lot).
The other rows use the realised STCG / indexed-LTCG figures.
"""
buckets: dict[str, List[Decimal]] = {
cat: [Decimal(0)] * len(QUARTER_LABELS) for cat in QUARTERLY_CATEGORIES
}
for gain in self.gains:
if gain.fy != fy:
continue
q = _quarter_index(gain.sale_date)
is_equity = gain.fund.type == FundType.EQUITY.name
if gain.gain_type == GainType.LTCG:
if is_equity:
buckets["Equity LTCG"][q] += _taxable_112a(gain)
else:
buckets["Debt LTCG"][q] += gain.ltcg_taxable
else:
buckets["Equity STCG" if is_equity else "Debt STCG"][q] += gain.stcg
return buckets
1 change: 1 addition & 0 deletions casparser/analysis/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"FY2023-24": 348,
"FY2024-25": 363,
"FY2025-26": 376,
"FY2026-27": 384,
}


Expand Down
56 changes: 55 additions & 1 deletion casparser/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from rich.table import Table

from . import __version__, read_cas_pdf
from .analysis.gains import CapitalGainsReport
from .analysis.gains import QUARTER_LABELS, QUARTERLY_CATEGORIES, CapitalGainsReport
from .enums import CASFileType, FileType
from .exceptions import GainsError, IncompleteCASError, ParserException
from .parsers.utils import cas2csv, cas2csv_summary, cas2json, is_close
Expand Down Expand Up @@ -392,6 +392,58 @@ def print_gifts(cg: CapitalGainsReport):
)


def print_quarterly(cg: CapitalGainsReport):
"""Print the quarterly split of realised gains (Schedule CG, Section F) —
one table per FY, bucketed by date of transfer into the five advance-tax
installment windows that drive the 234C interest calc."""

# Whole-rupee INR (tax figures are integers; keeps the 7-column table narrow).
def inr0(x):
return formatINR(Decimal(round(x))).removesuffix(".00")

for fy in cg.get_fy_list():
buckets = cg.quarterly_gains(fy)
# Skip categories with no activity in the year to keep the table tight.
active = {cat: qs for cat, qs in buckets.items() if any(qs)}
if not active:
continue

table = Table(
title=f"Quarterly Capital Gains {fy} (taxable, by date of transfer)",
show_lines=True,
)
table.add_column("Category", no_wrap=True)
for label in QUARTER_LABELS:
table.add_column(label, justify="right")
table.add_column("Total", justify="right")

column_totals = [Decimal(0)] * len(QUARTER_LABELS)
for cat in QUARTERLY_CATEGORIES:
if cat not in active:
continue
quarters = active[cat]
row_total = sum(quarters)
table.add_row(
cat,
*[inr0(q) for q in quarters],
f"[bold]{inr0(row_total)}[/]",
)
column_totals = [a + b for a, b in zip(column_totals, quarters)]

grand_total = sum(column_totals)
table.add_row(
"[bold]Total[/]",
*[f"[bold {get_color(t)}]{inr0(t)}[/]" for t in column_totals],
f"[bold {get_color(grand_total)}]{inr0(grand_total)}[/]",
)
console.print(table)
console.print(
"[dim]Equity LTCG totals reconcile with the Schedule 112A report. "
"Gains are placed by date of transfer; the five windows are the "
"advance-tax installment periods (uneven — the last is 16–31 Mar).[/]"
)


def print_gains(parsed_data: CASData, output_file_path=None, gains_112a=""):
cg = CapitalGainsReport(parsed_data)
data = parsed_data.model_dump(by_alias=True)
Expand Down Expand Up @@ -442,6 +494,8 @@ def print_gains(parsed_data: CASData, output_file_path=None, gains_112a=""):
)
console.print(table)

print_quarterly(cg)

if gains_112a != "":
if output_file_path is None:
console.print(
Expand Down
Loading