diff --git a/casparser/analysis/gains.py b/casparser/analysis/gains.py index 6a7b31d..2542be5 100644 --- a/casparser/analysis/gains.py +++ b/casparser/analysis/gains.py @@ -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 @@ -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: @@ -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 @@ -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""" @@ -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. @@ -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): @@ -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 diff --git a/casparser/analysis/utils.py b/casparser/analysis/utils.py index 26e34e9..8fa3eb9 100644 --- a/casparser/analysis/utils.py +++ b/casparser/analysis/utils.py @@ -32,6 +32,7 @@ "FY2023-24": 348, "FY2024-25": 363, "FY2025-26": 376, + "FY2026-27": 384, } diff --git a/casparser/cli.py b/casparser/cli.py index f0bedb6..c02cd58 100644 --- a/casparser/cli.py +++ b/casparser/cli.py @@ -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 @@ -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) @@ -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( diff --git a/tests/test_gains.py b/tests/test_gains.py index 4b65ac6..2837ee2 100644 --- a/tests/test_gains.py +++ b/tests/test_gains.py @@ -9,9 +9,13 @@ Fund, FundType, GainEntry, + GainEntry112A, GiftEntry, MergedTransaction, + _consolidate_ae_112a, + _fy_consolidates_ae, _fy_needs_transfer_col, + _quarter_index, _transfer_flag, get_fund_type, ) @@ -70,6 +74,7 @@ def test_cii(self): assert CII["FY2023-24"] == 348 assert CII["FY2024-25"] == 363 assert CII["FY2025-26"] == 376 + assert CII["FY2026-27"] == 384 # Checks for out-of-range FYs today = date.today() @@ -429,12 +434,87 @@ def test_transfer_flag(self): assert _transfer_flag(date(2020, 1, 1)) == "BE" def test_fy_needs_transfer_col(self): + # Column 1b exists only for FY2024-25 — the single year straddling + # the 23-Jul-2024 rate change. FY2025-26 onward is one regime again. assert _fy_needs_transfer_col("FY2024-25") is True - assert _fy_needs_transfer_col("FY2025-26") is True + assert _fy_needs_transfer_col("FY2025-26") is False + assert _fy_needs_transfer_col("FY2026-27") is False assert _fy_needs_transfer_col("FY2023-24") is False assert _fy_needs_transfer_col("FY2020-21") is False assert _fy_needs_transfer_col("") is False + def test_fy_consolidates_ae(self): + assert _fy_consolidates_ae("FY2025-26") is True + assert _fy_consolidates_ae("FY2026-27") is True + assert _fy_consolidates_ae("FY2024-25") is False + assert _fy_consolidates_ae("FY2023-24") is False + assert _fy_consolidates_ae("") is False + + def test_consolidate_ae_112a(self): + def _ae(isin, sale, cost, stt, stamp): + return GainEntry112A( + "AE", + "AE", + isin, + isin, + Decimal("10"), + Decimal("5"), + Decimal(sale), + Decimal(cost), + Decimal(0), + Decimal(0), + Decimal(stt), + Decimal(stamp), + ) + + be = GainEntry112A( + "BE", + "AE", + "INF000A01001", + "Grandfathered Fund", + Decimal("5"), + Decimal("100"), + Decimal("500"), + Decimal("300"), + Decimal("80"), + Decimal("400"), + Decimal("1"), + Decimal("2"), + ) + rows = [ + be, + _ae("INF000A01002", "1000", "800", "1", "3"), + _ae("INF000A01003", "2000", "1500", "2", "4"), + ] + out = _consolidate_ae_112a(rows) + assert len(out) == 2 # BE kept + one merged AE + assert out[0] is be # grandfathered row itemised, untouched + m = out[1] + assert m.acquired == "AE" and m.isin == "INNOTREQUIRD" and m.name == "CONSOLIDATED" + assert m.units == Decimal(0) and m.sale_nav == Decimal(0) + assert m.sale_value == Decimal("3000") # 1000 + 2000 + assert m.purchase_value == Decimal("2300") # 800 + 1500 + # cost of acquisition folds in stamp duty (s.55); STT excluded + assert m.deductions == Decimal("2307") # 2300 + (3+4) stamp + assert m.balance == Decimal("693") # 3000 - 2307 + + def test_consolidate_ae_112a_no_ae_rows(self): + be = GainEntry112A( + "BE", + "AE", + "INF000A01001", + "F", + Decimal("5"), + Decimal("100"), + Decimal("500"), + Decimal("300"), + Decimal("80"), + Decimal("400"), + Decimal("1"), + Decimal("2"), + ) + assert _consolidate_ae_112a([be]) == [be] + def test_ae_lots_split_across_cutoff(self): """An after-31-Jan-2018-acquired fund sold both before and on/after 23-Jul-2024 within FY2024-25 yields TWO consolidated 112A rows — @@ -489,6 +569,111 @@ def test_csv_omits_1b_column_for_older_fy(self): assert cols[0] == "Share/Unit acquired(1a)" assert cols[1] == "ISIN Code(2)" + def test_csv_fy2025_26_consolidated_ae_integers(self): + """FY2025-26: all after-2018 lots collapse into one CONSOLIDATED row; + only Full Value(6)/Cost(8)/Expenditure(12) carry values, amounts are + whole rupees, and stamp duty is folded into the cost (col 8).""" + f1 = Fund("Fund A", "A", "INF000A01001", "EQUITY") + f2 = Fund("Fund B", "B", "INF000A01002", "EQUITY") + gains = [ + _ltcg_entry("FY2025-26", f1, date(2022, 1, 1), date(2025, 6, 1)), + _ltcg_entry("FY2025-26", f2, date(2023, 1, 1), date(2025, 7, 1)), + ] + csv_data = _report_with_gains(gains).generate_112a_csv_data("FY2025-26") + assert "Transferred(1b)" not in csv_data.splitlines()[0] # no 1b col + data = csv_data.splitlines()[1:] + assert len(data) == 1 # single consolidated row across both funds + c = data[0].split(",") + assert c[0] == "AE" and c[1] == "INNOTREQUIRD" and c[2] == "CONSOLIDATED" + assert c[3] == "0" and c[4] == "0" # units / sale-price unused for AE + assert c[5] == "4000" # Full Value(6) = 2000 + 2000 (gross, units*nav) + assert c[6] == "2002" # Cost w/o indexation(7) = higher of 8, 9 = 8 + assert c[7] == "2002" # Cost(8) = (1000+1000) + (1+1) stamp duty + assert c[8:11] == ["0"] * 3 # cols 9,10,11 N/A (grandfathering) + assert c[11] == "0" # Expenditure(12) — STT not deductible + assert c[12] == "2002" # Total deductions(13) = 7 + 12 + assert c[13] == "1998" # Balance(14) = 6 - 13 = 4000 - 2002 + assert "." not in data[0] # no decimals anywhere + + +def _entry(fy, ftype, purchase_date, sale_date): + """Generic GainEntry for the quarterly-split tests: purchase 1000 + 1 + stamp, sale 2000. The fund type + hold length decide the category.""" + fund = Fund(f"{ftype} Fund", "X", "INF000X01001", ftype) + return GainEntry( + fy=fy, + fund=fund, + type=ftype, + purchase_date=purchase_date, + purchase_nav=Decimal("10.0"), + purchase_value=Decimal("1000.00"), + stamp_duty=Decimal("1.00"), + sale_date=sale_date, + sale_nav=Decimal("20.0"), + sale_value=Decimal("2000.00"), + stt=Decimal("2.00"), + units=Decimal("100.000"), + ) + + +class TestQuarterlyGains: + """Schedule CG Section F — realised gains split into the five advance-tax + installment windows by date of transfer (drives the 234C calc).""" + + def test_quarter_index_boundaries(self): + # Windows are inclusive of the closing date, in fiscal (Apr-Mar) order. + assert _quarter_index(date(2025, 4, 1)) == 0 # start of FY + assert _quarter_index(date(2025, 6, 15)) == 0 + assert _quarter_index(date(2025, 6, 16)) == 1 + assert _quarter_index(date(2025, 9, 15)) == 1 + assert _quarter_index(date(2025, 9, 16)) == 2 + assert _quarter_index(date(2025, 12, 15)) == 2 + assert _quarter_index(date(2025, 12, 16)) == 3 + assert _quarter_index(date(2026, 1, 31)) == 3 # Jan folds into Q4 + assert _quarter_index(date(2026, 3, 15)) == 3 + assert _quarter_index(date(2026, 3, 16)) == 4 # 16-31 Mar sliver + assert _quarter_index(date(2026, 3, 31)) == 4 + + def test_equity_ltcg_buckets_and_112a_reconciliation(self): + f1 = Fund("Fund A", "A", "INF000A01001", "EQUITY") + f2 = Fund("Fund B", "B", "INF000A01002", "EQUITY") + gains = [ + _ltcg_entry("FY2025-26", f1, date(2022, 1, 1), date(2025, 5, 1)), # Q1 + _ltcg_entry("FY2025-26", f2, date(2023, 1, 1), date(2026, 3, 20)), # Q5 + ] + rep = _report_with_gains(gains) + buckets = rep.quarterly_gains("FY2025-26") + # taxable per lot (AE) = 2000 - (1000 + 1 stamp) = 999 + assert buckets["Equity LTCG"][0] == Decimal("999.00") + assert buckets["Equity LTCG"][4] == Decimal("999.00") + assert buckets["Equity LTCG"][1] == Decimal("0") + # The equity-LTCG total ties out to the Schedule 112A balance total. + balance_total = sum(r.balance for r in rep.generate_112a("FY2025-26")) + assert sum(buckets["Equity LTCG"]) == balance_total + + def test_categorises_equity_debt_ltcg_stcg(self): + gains = [ + _entry("FY2025-26", "EQUITY", date(2025, 4, 1), date(2025, 8, 1)), # eq STCG Q2 + _entry("FY2025-26", "DEBT", date(2024, 1, 1), date(2025, 6, 1)), # debt STCG Q1 + _entry("FY2025-26", "DEBT", date(2020, 1, 1), date(2025, 10, 1)), # debt LTCG Q3 + ] + b = _report_with_gains(gains).quarterly_gains("FY2025-26") + # STCG = sale - (purchase + stamp) = 2000 - 1001 = 999 + assert b["Equity STCG"][1] == Decimal("999.00") + assert sum(b["Equity STCG"]) == Decimal("999.00") + assert b["Debt STCG"][0] == Decimal("999.00") + assert b["Debt LTCG"][2] > 0 # indexed-cost LTCG lands in Q3 + assert sum(b["Equity LTCG"]) == Decimal("0") + + def test_only_requested_fy_included(self): + f = Fund("Fund A", "A", "INF000A01001", "EQUITY") + gains = [ + _ltcg_entry("FY2024-25", f, date(2022, 1, 1), date(2024, 5, 1)), + _ltcg_entry("FY2025-26", f, date(2022, 1, 1), date(2025, 5, 1)), + ] + b = _report_with_gains(gains).quarterly_gains("FY2025-26") + assert sum(sum(qs) for qs in b.values()) == Decimal("999.00") + class TestStampDutyInCostOfAcquisition: """Purchase-side stamp duty is part of the cost of acquisition.