Skip to content

Commit 4e19219

Browse files
committed
gains: support for generating gains in ITR SCH-112A format
1 parent e60e82f commit 4e19219

File tree

4 files changed

+230
-16
lines changed

4 files changed

+230
-16
lines changed

casparser/analysis/gains.py

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import date
66
import io
77
import itertools
8-
from typing import List
8+
from typing import List, Optional
99

1010
from dateutil.parser import parse as dateparse
1111
from dateutil.relativedelta import relativedelta
@@ -33,6 +33,46 @@
3333
}
3434

3535

36+
@dataclass
37+
class GainEntry112A:
38+
"""GainEntry for schedule 112A of ITR."""
39+
40+
acquired: str # AE, BE
41+
isin: str
42+
name: str
43+
units: Decimal
44+
sale_nav: Decimal
45+
sale_value: Decimal
46+
purchase_value: Decimal
47+
fmv_nav: Decimal
48+
fmv: Decimal
49+
stt: Decimal
50+
stamp_duty: Decimal
51+
52+
@property
53+
def consideration_value(self):
54+
if self.acquired == "BE":
55+
return min(self.fmv, self.sale_value)
56+
else:
57+
return Decimal("0.00") # FMV not considered
58+
59+
@property
60+
def actual_coa(self):
61+
return max(self.purchase_value, self.consideration_value)
62+
63+
@property
64+
def expenditure(self):
65+
return self.stt + self.stamp_duty
66+
67+
@property
68+
def deductions(self):
69+
return self.actual_coa + self.expenditure
70+
71+
@property
72+
def balance(self):
73+
return self.sale_value - self.deductions
74+
75+
3676
@dataclass
3777
class MergedTransaction:
3878
"""Represent net transaction on a given date"""
@@ -73,12 +113,17 @@ def add(self, txn: TransactionDataType):
73113
class Fund:
74114
"""Fund details"""
75115

76-
name: str
116+
scheme: str
117+
folio: str
77118
isin: str
78119
type: str
79120

121+
@property
122+
def name(self):
123+
return f"{self.scheme} [{self.folio}]"
124+
80125
def __lt__(self, other: "Fund"):
81-
return self.name < other.name
126+
return self.scheme < other.scheme
82127

83128

84129
@dataclass
@@ -89,9 +134,11 @@ class GainEntry:
89134
fund: Fund
90135
type: str
91136
purchase_date: date
137+
purchase_nav: Decimal
92138
purchase_value: Decimal
93139
stamp_duty: Decimal
94140
sale_date: date
141+
sale_nav: Decimal
95142
sale_value: Decimal
96143
stt: Decimal
97144
units: Decimal
@@ -120,12 +167,16 @@ def gain(self) -> Decimal:
120167
return Decimal(round(self.sale_value - self.purchase_value, 2))
121168

122169
@property
123-
def fmv(self) -> Decimal:
170+
def fmv_nav(self) -> Decimal:
124171
if self.fund.isin != self._cached_isin:
125172
self.__update_nav()
126-
if self._cached_nav is None:
173+
return self._cached_nav
174+
175+
@property
176+
def fmv(self) -> Decimal:
177+
if self.fmv_nav is None:
127178
return self.purchase_value
128-
return self._cached_nav * self.units
179+
return self.fmv_nav * self.units
129180

130181
@property
131182
def index_ratio(self) -> Decimal:
@@ -270,9 +321,11 @@ def sell(self, sell_date: date, quantity: Decimal, nav: Decimal, tax: Decimal):
270321
fund=self._fund,
271322
type=self.fund_type.name,
272323
purchase_date=purchase_date,
324+
purchase_nav=purchase_nav,
273325
purchase_value=purchase_value,
274326
stamp_duty=stamp_duty,
275327
sale_date=sell_date,
328+
sale_nav=nav,
276329
sale_value=sale_value,
277330
stt=stt,
278331
units=gain_units,
@@ -306,30 +359,39 @@ def __init__(self, data: CASParserDataType):
306359
def gains(self) -> List[GainEntry]:
307360
return list(sorted(self._gains, key=lambda x: (x.fy, x.fund, x.sale_date)))
308361

362+
def has_gains(self) -> bool:
363+
return len(self.gains) > 0
364+
309365
def has_error(self) -> bool:
310366
return len(self.errors) > 0
311367

368+
def get_fy_list(self) -> List[str]:
369+
return list(sorted(set([f.fy for f in self.gains]), reverse=True))
370+
312371
def process_data(self):
313372
self._gains = []
314373
for folio in self._data.get("folios", []):
315374
for scheme in folio.get("schemes", []):
316-
name = f"{scheme['scheme']} [{folio['folio']}]"
317375
transactions = scheme["transactions"]
376+
fund = Fund(
377+
scheme=scheme["scheme"],
378+
folio=folio["folio"],
379+
isin=scheme["isin"],
380+
type=scheme["type"],
381+
)
318382
if len(transactions) > 0:
319383
if scheme["open"] >= 0.01:
320384
raise IncompleteCASError(
321385
"Incomplete CAS found. For gains computation, "
322386
"all folios should have zero opening balance"
323387
)
324388
try:
325-
fifo = FIFOUnits(
326-
Fund(name=name, isin=scheme["isin"], type=scheme["type"]), transactions
327-
)
389+
fifo = FIFOUnits(fund, transactions)
328390
self.invested_amount += fifo.invested
329391
self.current_value += scheme["valuation"]["value"]
330392
self._gains.extend(fifo.gains)
331393
except GainsError as exc:
332-
self.errors.append((name, str(exc)))
394+
self.errors.append((fund.name, str(exc)))
333395

334396
def get_summary(self):
335397
"""Calculate capital gains summary"""
@@ -400,3 +462,100 @@ def get_gains_csv_data(self) -> str:
400462
csv_fp.seek(0)
401463
csv_data = csv_fp.read()
402464
return csv_data
465+
466+
def generate_112a(self, fy) -> List[GainEntry112A]:
467+
fy_transactions = sorted(
468+
list(filter(lambda x: x.fy == fy and x.fund.type == "EQUITY", self.gains)),
469+
key=lambda x: x.fund,
470+
)
471+
rows: List[GainEntry112A] = []
472+
for fund, txns in itertools.groupby(fy_transactions, key=lambda x: x.fund):
473+
consolidated_entry: Optional[GainEntry112A] = None
474+
entries = []
475+
for txn in txns:
476+
if txn.purchase_date <= date(2018, 1, 31):
477+
entries.append(
478+
GainEntry112A(
479+
"BE",
480+
fund.isin,
481+
fund.scheme,
482+
txn.units,
483+
txn.sale_nav,
484+
txn.sale_value,
485+
txn.purchase_value,
486+
txn.fmv_nav,
487+
txn.fmv,
488+
txn.stt,
489+
txn.stamp_duty,
490+
)
491+
)
492+
else:
493+
if consolidated_entry is None:
494+
consolidated_entry = GainEntry112A(
495+
"AE",
496+
fund.isin,
497+
fund.scheme,
498+
txn.units,
499+
txn.sale_nav,
500+
txn.sale_value,
501+
txn.purchase_value,
502+
Decimal(0.0),
503+
Decimal(0.0),
504+
txn.stt,
505+
txn.stamp_duty,
506+
)
507+
else:
508+
consolidated_entry.purchase_value += txn.purchase_value
509+
consolidated_entry.stt += txn.stt
510+
consolidated_entry.stamp_duty += txn.stamp_duty
511+
consolidated_entry.units += txn.units
512+
consolidated_entry.sale_value += txn.sale_value
513+
consolidated_entry.sale_nav = Decimal(round(txn.sale_value / txn.units, 3))
514+
rows.extend(entries)
515+
if consolidated_entry is not None:
516+
rows.append(consolidated_entry)
517+
return rows
518+
519+
def generate_112a_csv_data(self, fy):
520+
headers = [
521+
"Share/Unit acquired(1a)",
522+
"ISIN Code(2)",
523+
"Name of the Share/Unit(3)",
524+
"No. of Shares/Units(4)",
525+
"Sale-price per Share/Unit(5)",
526+
"Full Value of Consideration(Total Sale Value)(6) = 4 * 5",
527+
"Cost of acquisition without indexation(7)",
528+
"Cost of acquisition(8)",
529+
"If the long term capital asset was acquired before 01.02.2018(9)",
530+
"Fair Market Value per share/unit as on 31st January 2018(10)",
531+
"Total Fair Market Value of capital asset as per section 55(2)(ac)(11) = 4 * 10",
532+
"Expenditure wholly and exclusively in connection with transfer(12)",
533+
"Total deductions(13) = 7 + 12",
534+
"Balance(14) = 6 - 13",
535+
]
536+
with io.StringIO() as csv_fp:
537+
writer = csv.writer(csv_fp)
538+
writer.writerow(headers)
539+
540+
for row in self.generate_112a(fy):
541+
writer.writerow(
542+
[
543+
row.acquired,
544+
row.isin,
545+
row.name,
546+
str(row.units),
547+
str(row.sale_nav),
548+
str(row.sale_value),
549+
str(row.actual_coa),
550+
str(row.purchase_value),
551+
str(row.consideration_value),
552+
str(row.fmv_nav),
553+
str(row.fmv),
554+
str(row.expenditure),
555+
str(row.deductions),
556+
str(row.balance),
557+
]
558+
)
559+
csv_fp.seek(0)
560+
csv_data = csv_fp.read()
561+
return csv_data

casparser/cli.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rich.markdown import Markdown
1111
from rich.padding import Padding
1212
from rich.progress import BarColumn, TextColumn, SpinnerColumn, Progress
13+
from rich.prompt import Confirm, Prompt
1314
from rich.table import Table
1415

1516
from .__version__ import __version__
@@ -24,6 +25,10 @@
2425
console = Console()
2526

2627

28+
def validate_fy(ctx, param, value):
29+
return re.search(r"FY\d{4}-\d{2,4}", value, re.I) is not None
30+
31+
2732
def get_color(amount: Union[Decimal, float, int]):
2833
"""Coloured printing"""
2934
if amount >= 1e-3:
@@ -147,8 +152,13 @@ def print_summary(data, output_filename=None, include_zero_folios=False):
147152
console.print(f"File saved : [bold]{output_filename}[/]")
148153

149154

150-
def print_gains(data, output_file_path=None):
155+
def print_gains(data, output_file_path=None, gains_112a=""):
151156
cg = CapitalGainsReport(data)
157+
158+
if not cg.has_gains():
159+
console.print("[bold yellow]Warning:[/] No capital gains info found in CAS")
160+
return
161+
152162
summary = cg.get_summary()
153163
table = Table(title="Capital Gains statement (Realised)", show_lines=True)
154164
table.add_column("FY", no_wrap=True)
@@ -182,6 +192,17 @@ def print_gains(data, output_file_path=None):
182192
f"[bold {get_color(stcg_total)}]₹{round(stcg_total, 2)}[/]",
183193
)
184194
console.print(table)
195+
196+
if gains_112a != "":
197+
if output_file_path is None:
198+
console.print(
199+
"[bold yellow]Warning:[/] `gains_112a` option requires an output "
200+
"csv file path via `-o` argument. Cannot continue..."
201+
)
202+
return
203+
204+
save_gains_112a(cg, gains_112a, output_file_path)
205+
185206
if isinstance(output_file_path, str):
186207
base_path, ext = os.path.splitext(output_file_path)
187208
if not ext.lower().endswith("csv"):
@@ -209,6 +230,27 @@ def print_gains(data, output_file_path=None):
209230
console.print(f"{'Absolute PnL':20s}: [bold {get_color(pnl)}]₹{pnl:,.2f}[/]")
210231

211232

233+
def save_gains_112a(capital_gains: CapitalGainsReport, fy, output_path):
234+
fy = fy.upper()
235+
fy_list = capital_gains.get_fy_list()
236+
if fy == "ASK":
237+
fy = Prompt.ask("Enter FY year: ", choices=fy_list, default=fy_list[0])
238+
else:
239+
if fy.upper() not in fy_list:
240+
console.print(
241+
f"[bold red]Warning:[/] No capital gains found for {fy}. "
242+
f"Please try with `--gains112a ask` option"
243+
)
244+
return
245+
base_path, ext = os.path.splitext(output_path)
246+
csv_data = capital_gains.generate_112a_csv_data(fy.upper())
247+
fname = f"{base_path}-{fy}-gains-112a.csv"
248+
249+
with open(fname, "w", newline="", encoding="utf-8") as fp:
250+
fp.write(csv_data)
251+
console.print(f"gains report (112a) saved : [bold]{fname}[/]")
252+
253+
212254
@click.command(name="casparser", context_settings=CONTEXT_SETTINGS)
213255
@click.option(
214256
"-o",
@@ -238,12 +280,19 @@ def print_gains(data, output_file_path=None):
238280
help="Include schemes with zero valuation in the summary output",
239281
)
240282
@click.option("-g", "--gains", is_flag=True, help="Generate Capital Gains Report (BETA)")
283+
@click.option(
284+
"--gains-112a",
285+
help="Generate Capital Gains Report - 112A format for a financial year - "
286+
"Use 'ask' for a prompt from available options (BETA)",
287+
default="",
288+
metavar="ask|FY2020-21",
289+
)
241290
@click.option(
242291
"--force-pdfminer", is_flag=True, help="Force PDFMiner parser even if MuPDF is detected"
243292
)
244293
@click.version_option(__version__, prog_name="casparser-cli")
245294
@click.argument("filename", type=click.Path(exists=True), metavar="CAS_PDF_FILE")
246-
def cli(output, summary, password, include_all, gains, force_pdfminer, filename):
295+
def cli(output, summary, password, include_all, gains, gains_112a, force_pdfminer, filename):
247296
"""CLI function."""
248297
output_ext = None
249298
if output is not None:
@@ -285,9 +334,13 @@ def cli(output, summary, password, include_all, gains, force_pdfminer, filename)
285334
with open(output, "w", newline="", encoding="utf-8") as fp:
286335
fp.write(conv_fn(data))
287336
console.print(f"File saved : [bold]{output}[/]")
288-
if gains:
337+
if gains or gains_112a:
289338
try:
290-
print_gains(data, output_file_path=output if output_ext == ".csv" else None)
339+
print_gains(
340+
data,
341+
output_file_path=output if output_ext == ".csv" else None,
342+
gains_112a=gains_112a,
343+
)
291344
except IncompleteCASError:
292345
console.print("[bold red]Error![/] - Cannot compute gains. CAS is incomplete!")
293346
sys.exit(2)

tests/test_gains.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_merge_transaction(self):
107107
assert mt.tds == Decimal("1.25")
108108

109109
def test_gains_error(self):
110-
test_fund = Fund("demo fund", "INF123456789", "EQUITY")
110+
test_fund = Fund("demo fund", "123", "INF123456789", "EQUITY")
111111
dt = date(2000, 1, 1)
112112
transactions = [
113113
{

0 commit comments

Comments
 (0)