itr-tool 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itr_tool/__init__.py +2 -0
- itr_tool/cli.py +314 -0
- itr_tool/config.py +271 -0
- itr_tool/config_migrate.py +57 -0
- itr_tool/config_wizard.py +455 -0
- itr_tool/engine/__init__.py +0 -0
- itr_tool/engine/compute.py +310 -0
- itr_tool/engine/interest_234.py +138 -0
- itr_tool/engine/itr_form_selector.py +91 -0
- itr_tool/engine/reconcile.py +91 -0
- itr_tool/engine/regime_new.py +141 -0
- itr_tool/engine/regime_old.py +97 -0
- itr_tool/engine/section_89.py +67 -0
- itr_tool/models/__init__.py +0 -0
- itr_tool/models/advance_tax.py +11 -0
- itr_tool/models/capital_gains.py +136 -0
- itr_tool/models/computation.py +75 -0
- itr_tool/models/deductions.py +59 -0
- itr_tool/models/foreign_assets.py +40 -0
- itr_tool/models/other_income.py +22 -0
- itr_tool/models/personal.py +46 -0
- itr_tool/models/salary.py +56 -0
- itr_tool/output/__init__.py +0 -0
- itr_tool/output/itr1_json.py +204 -0
- itr_tool/output/itr2_json.py +400 -0
- itr_tool/output/pdf_report.py +189 -0
- itr_tool/output/sti_report.py +494 -0
- itr_tool/output/templates/computation.html +148 -0
- itr_tool/output/yoy_report.py +109 -0
- itr_tool/parsers/__init__.py +0 -0
- itr_tool/parsers/ais.py +311 -0
- itr_tool/parsers/ais_pdf.py +148 -0
- itr_tool/parsers/bank_statement.py +226 -0
- itr_tool/parsers/dispatcher.py +371 -0
- itr_tool/parsers/employer_perq.py +127 -0
- itr_tool/parsers/etrade_benefit.py +7 -0
- itr_tool/parsers/etrade_gl.py +186 -0
- itr_tool/parsers/form16.py +230 -0
- itr_tool/parsers/form26as.py +250 -0
- itr_tool/parsers/mf_capital_gains.py +55 -0
- itr_tool/parsers/password_wizard.py +189 -0
- itr_tool/parsers/rates.py +11 -0
- itr_tool/parsers/sbi_ttbr.py +103 -0
- itr_tool/parsers/tis.py +108 -0
- itr_tool/parsers/vestwise.py +2336 -0
- itr_tool/parsers/vestwise_data/SBI_REFERENCE_RATES_USD.csv +1601 -0
- itr_tool/parsers/vestwise_data/sale_price_overrides.csv +37 -0
- itr_tool/parsers/zerodha.py +93 -0
- itr_tool/rules/__init__.py +0 -0
- itr_tool/rules/fy2020_21.json +119 -0
- itr_tool/rules/fy2021_22.json +120 -0
- itr_tool/rules/fy2022_23.json +132 -0
- itr_tool/rules/fy2023_24.json +133 -0
- itr_tool/rules/fy2024_25.json +156 -0
- itr_tool/rules/fy2025_26.json +150 -0
- itr_tool/web/__init__.py +0 -0
- itr_tool/web/server.py +347 -0
- itr_tool-1.0.0.dist-info/METADATA +548 -0
- itr_tool-1.0.0.dist-info/RECORD +63 -0
- itr_tool-1.0.0.dist-info/WHEEL +5 -0
- itr_tool-1.0.0.dist-info/entry_points.txt +2 -0
- itr_tool-1.0.0.dist-info/licenses/LICENSE +21 -0
- itr_tool-1.0.0.dist-info/top_level.txt +1 -0
itr_tool/__init__.py
ADDED
itr_tool/cli.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""CLI entry point for itr-tool."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from itr_tool import __version__
|
|
11
|
+
from itr_tool.config import load_config
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(version=__version__, prog_name="itr-tool")
|
|
18
|
+
def cli():
|
|
19
|
+
"""itr-tool — Offline Indian Income Tax computation tool.
|
|
20
|
+
|
|
21
|
+
Privacy-first, offline CLI for salaried individuals with ESOP/ESPP/RSU.
|
|
22
|
+
Produces STI reports, ITR-2 JSON, and PDF computation sheets.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.command()
|
|
28
|
+
@click.argument("folder", type=click.Path(exists=True, file_okay=False))
|
|
29
|
+
@click.option("--regime", type=click.Choice(["old", "new", "both"]), default="both",
|
|
30
|
+
help="Tax regime to compute.")
|
|
31
|
+
@click.option("--fy", default=None, help="Financial year (e.g. 2025-26). Auto-detected from config.yaml.")
|
|
32
|
+
@click.option("--ais-password", default="", help="AIS decrypt password (PAN+DOB). Prompted if needed.")
|
|
33
|
+
@click.option("--output", "-o", type=click.Path(), default=None,
|
|
34
|
+
help="Output directory. Default: <folder>/output/")
|
|
35
|
+
@click.option("--no-wizard", is_flag=True, default=False,
|
|
36
|
+
help="Skip interactive config wizard (use existing config.yaml as-is).")
|
|
37
|
+
def compute(folder, regime, fy, ais_password, output, no_wizard):
|
|
38
|
+
"""Run full tax computation from an organized document folder.
|
|
39
|
+
|
|
40
|
+
FOLDER should contain subfolders: form16/, etrade/, broker/, mf/, bank/, it/
|
|
41
|
+
"""
|
|
42
|
+
folder_path = Path(folder)
|
|
43
|
+
console.print(Panel.fit(
|
|
44
|
+
f"[bold blue]itr-tool v{__version__}[/] — Tax Computation",
|
|
45
|
+
subtitle=f"FY {fy or 'auto'} | Regime: {regime}",
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
# Load config (initial — may be incomplete)
|
|
49
|
+
with console.status("[bold green]Loading configuration..."):
|
|
50
|
+
cfg = load_config(folder_path, fy=fy, regime=regime, ais_password=ais_password)
|
|
51
|
+
if output:
|
|
52
|
+
cfg.output_folder = Path(output)
|
|
53
|
+
cfg.output_folder.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
# Phase 1: Parse all documents (before wizard — to auto-detect values)
|
|
56
|
+
console.rule("[bold]Phase 1: Scanning & Parsing Documents")
|
|
57
|
+
from itr_tool.parsers.dispatcher import discover_and_parse
|
|
58
|
+
parsed = discover_and_parse(cfg)
|
|
59
|
+
|
|
60
|
+
# Interactive config wizard
|
|
61
|
+
config_path = folder_path / "config.yaml"
|
|
62
|
+
if not no_wizard:
|
|
63
|
+
import yaml
|
|
64
|
+
from itr_tool.config_wizard import run_config_wizard, save_config, show_config_summary
|
|
65
|
+
|
|
66
|
+
# Load existing raw config
|
|
67
|
+
existing_raw: dict = {}
|
|
68
|
+
if config_path.exists():
|
|
69
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
70
|
+
existing_raw = yaml.safe_load(f) or {}
|
|
71
|
+
|
|
72
|
+
# Run wizard — auto-fills from parsed data, prompts user for each field
|
|
73
|
+
updated_cfg = run_config_wizard(folder_path, existing_raw, parsed, fy=fy)
|
|
74
|
+
|
|
75
|
+
# Show summary and confirm
|
|
76
|
+
show_config_summary(updated_cfg)
|
|
77
|
+
console.print()
|
|
78
|
+
save_it = click.confirm(" Save this configuration?", default=True)
|
|
79
|
+
if save_it:
|
|
80
|
+
saved_path = save_config(folder_path, updated_cfg)
|
|
81
|
+
console.print(f" [green]Saved:[/] {saved_path}")
|
|
82
|
+
|
|
83
|
+
# Reload config from saved file
|
|
84
|
+
cfg = load_config(folder_path, fy=fy, regime=regime, ais_password=ais_password)
|
|
85
|
+
if output:
|
|
86
|
+
cfg.output_folder = Path(output)
|
|
87
|
+
cfg.output_folder.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
# Re-parse with updated config (e.g. AIS password may have changed)
|
|
90
|
+
console.print()
|
|
91
|
+
console.rule("[bold]Re-parsing with updated config")
|
|
92
|
+
parsed = discover_and_parse(cfg)
|
|
93
|
+
else:
|
|
94
|
+
console.print(f" PAN: [bold]{cfg.personal.pan or '(not set)'}[/]")
|
|
95
|
+
console.print(f" FY: [bold]{cfg.personal.financial_year}[/] -> AY {cfg.personal.assessment_year}")
|
|
96
|
+
|
|
97
|
+
console.print(f" Input: {cfg.input_folder}")
|
|
98
|
+
console.print(f" Output: {cfg.output_folder}")
|
|
99
|
+
console.print()
|
|
100
|
+
|
|
101
|
+
# Phase 2: Compute tax
|
|
102
|
+
console.rule("[bold]Phase 2: Tax Computation")
|
|
103
|
+
from itr_tool.engine.compute import compute_tax
|
|
104
|
+
result = compute_tax(parsed, cfg)
|
|
105
|
+
|
|
106
|
+
# Phase 2b: ITR form selection
|
|
107
|
+
from itr_tool.engine.itr_form_selector import select_itr_form
|
|
108
|
+
itr_form, form_reasons = select_itr_form(parsed, result, cfg)
|
|
109
|
+
console.print(f" [bold magenta]ITR Form: {itr_form}[/]")
|
|
110
|
+
for reason in form_reasons:
|
|
111
|
+
console.print(f" • {reason}")
|
|
112
|
+
|
|
113
|
+
# Phase 3: Generate outputs
|
|
114
|
+
console.rule("[bold]Phase 3: Generating Reports")
|
|
115
|
+
from itr_tool.output.sti_report import generate_sti
|
|
116
|
+
from itr_tool.output.pdf_report import generate_pdf
|
|
117
|
+
|
|
118
|
+
sti_path = generate_sti(result, parsed, cfg)
|
|
119
|
+
|
|
120
|
+
if itr_form == "ITR-1":
|
|
121
|
+
from itr_tool.output.itr1_json import generate_itr1_json
|
|
122
|
+
json_path = generate_itr1_json(result, parsed, cfg)
|
|
123
|
+
else:
|
|
124
|
+
from itr_tool.output.itr2_json import generate_itr2_json
|
|
125
|
+
json_path = generate_itr2_json(result, parsed, cfg)
|
|
126
|
+
|
|
127
|
+
pdf_path = generate_pdf(result, parsed, cfg)
|
|
128
|
+
|
|
129
|
+
# Phase 4: Vestwise — ESOP/ESPP detailed workbook (if BenefitHistory.xlsx found)
|
|
130
|
+
vestwise_path = None
|
|
131
|
+
benefit_files = list(cfg.input_folder.rglob("BenefitHistory.xlsx"))
|
|
132
|
+
if benefit_files:
|
|
133
|
+
console.rule("[bold]Phase 4: Vestwise ESOP/ESPP Workbook")
|
|
134
|
+
benefit_file = benefit_files[0]
|
|
135
|
+
vestwise_out = cfg.output_folder / f"ESOP_Summary_{cfg.personal.financial_year}.xlsx"
|
|
136
|
+
try:
|
|
137
|
+
from itr_tool.parsers.vestwise import run_vestwise
|
|
138
|
+
run_vestwise(str(benefit_file), str(vestwise_out))
|
|
139
|
+
vestwise_path = vestwise_out
|
|
140
|
+
console.print(f" [green]Vestwise:[/] {vestwise_path}")
|
|
141
|
+
except Exception as e:
|
|
142
|
+
console.print(f" [red]Vestwise failed: {e}[/]")
|
|
143
|
+
|
|
144
|
+
console.print()
|
|
145
|
+
output_lines = (
|
|
146
|
+
f"[bold green]Done![/]\n"
|
|
147
|
+
f" STI Report: {sti_path}\n"
|
|
148
|
+
f" ITR-2 JSON: {json_path}\n"
|
|
149
|
+
f" PDF/HTML: {pdf_path}"
|
|
150
|
+
)
|
|
151
|
+
if vestwise_path:
|
|
152
|
+
output_lines += f"\n ESOP Workbook: {vestwise_path}"
|
|
153
|
+
console.print(Panel.fit(output_lines, title="Output Files"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@cli.command()
|
|
157
|
+
@click.argument("folder", type=click.Path(exists=True, file_okay=False))
|
|
158
|
+
@click.option("--fy", default=None, help="Financial year (e.g. 2025-26).")
|
|
159
|
+
def init(folder, fy):
|
|
160
|
+
"""Create or update config.yaml interactively.
|
|
161
|
+
|
|
162
|
+
Scans FOLDER for tax documents, auto-detects values (PAN, employer,
|
|
163
|
+
salary, TDS), then walks you through each field to confirm or edit.
|
|
164
|
+
"""
|
|
165
|
+
folder_path = Path(folder)
|
|
166
|
+
console.print(Panel.fit(
|
|
167
|
+
f"[bold blue]itr-tool[/] — Configuration Setup",
|
|
168
|
+
subtitle=str(folder_path),
|
|
169
|
+
))
|
|
170
|
+
|
|
171
|
+
# Quick parse to auto-detect
|
|
172
|
+
cfg = load_config(folder_path, fy=fy)
|
|
173
|
+
console.rule("[bold]Scanning documents...")
|
|
174
|
+
from itr_tool.parsers.dispatcher import discover_and_parse
|
|
175
|
+
parsed = discover_and_parse(cfg)
|
|
176
|
+
|
|
177
|
+
# Run wizard
|
|
178
|
+
import yaml
|
|
179
|
+
from itr_tool.config_wizard import run_config_wizard, save_config, show_config_summary
|
|
180
|
+
existing_raw: dict = {}
|
|
181
|
+
config_path = folder_path / "config.yaml"
|
|
182
|
+
if config_path.exists():
|
|
183
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
184
|
+
existing_raw = yaml.safe_load(f) or {}
|
|
185
|
+
|
|
186
|
+
updated_cfg = run_config_wizard(folder_path, existing_raw, parsed, fy=fy)
|
|
187
|
+
show_config_summary(updated_cfg)
|
|
188
|
+
console.print()
|
|
189
|
+
if click.confirm(" Save this configuration?", default=True):
|
|
190
|
+
saved_path = save_config(folder_path, updated_cfg)
|
|
191
|
+
console.print(f" [green]Saved:[/] {saved_path}")
|
|
192
|
+
else:
|
|
193
|
+
console.print(" [yellow]Not saved.[/]")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.argument("file_type", type=click.Choice([
|
|
198
|
+
"form16", "ais", "etrade-gl", "etrade-benefit", "employer-perq",
|
|
199
|
+
"zerodha", "mf", "bank", "26as", "auto",
|
|
200
|
+
]))
|
|
201
|
+
@click.argument("file_path", type=click.Path(exists=True))
|
|
202
|
+
@click.option("--password", default="", help="Password for encrypted files (AIS).")
|
|
203
|
+
def parse(file_type, file_path, password):
|
|
204
|
+
"""Parse a single tax document and display structured output.
|
|
205
|
+
|
|
206
|
+
Supported types: form16, ais, etrade-gl, etrade-benefit, employer-perq,
|
|
207
|
+
zerodha, mf, bank, 26as, auto (auto-detect).
|
|
208
|
+
"""
|
|
209
|
+
console.print(f"[bold]Parsing[/] {file_path} as [cyan]{file_type}[/]")
|
|
210
|
+
from itr_tool.parsers.dispatcher import parse_single
|
|
211
|
+
result = parse_single(file_type, Path(file_path), password=password)
|
|
212
|
+
if result:
|
|
213
|
+
console.print_json(data=result)
|
|
214
|
+
else:
|
|
215
|
+
console.print("[red]Parse failed or no data extracted.[/]")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@cli.command()
|
|
219
|
+
@click.argument("folder", type=click.Path(exists=True, file_okay=False))
|
|
220
|
+
@click.option("--ais-password", default="", help="AIS decrypt password.")
|
|
221
|
+
def verify(folder, ais_password):
|
|
222
|
+
"""Cross-verify Form 16 vs AIS vs 26AS for mismatches.
|
|
223
|
+
|
|
224
|
+
Flags discrepancies in salary, TDS, interest, and dividends
|
|
225
|
+
before they become 143(1)(a) intimation issues.
|
|
226
|
+
"""
|
|
227
|
+
console.print(Panel.fit("[bold yellow]Cross-Verification[/]", subtitle=folder))
|
|
228
|
+
from itr_tool.engine.reconcile import reconcile
|
|
229
|
+
folder_path = Path(folder)
|
|
230
|
+
cfg = load_config(folder_path, ais_password=ais_password)
|
|
231
|
+
issues = reconcile(cfg)
|
|
232
|
+
if not issues:
|
|
233
|
+
console.print("[bold green]All sources match. No discrepancies found.[/]")
|
|
234
|
+
else:
|
|
235
|
+
table = Table(title="Discrepancies Found")
|
|
236
|
+
table.add_column("Item", style="cyan")
|
|
237
|
+
table.add_column("Source A", style="green")
|
|
238
|
+
table.add_column("Source B", style="yellow")
|
|
239
|
+
table.add_column("Diff", style="red")
|
|
240
|
+
for issue in issues:
|
|
241
|
+
table.add_row(issue["item"], issue["a"], issue["b"], issue["diff"])
|
|
242
|
+
console.print(table)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@cli.command()
|
|
246
|
+
@click.option("--port", default=8000, help="Port for web server.")
|
|
247
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to.")
|
|
248
|
+
def web(port, host):
|
|
249
|
+
"""Launch the web UI (FastAPI + React frontend)."""
|
|
250
|
+
console.print(f"[bold blue]Starting itr-tool web UI[/] at http://{host}:{port}")
|
|
251
|
+
import uvicorn
|
|
252
|
+
from itr_tool.web.server import app
|
|
253
|
+
uvicorn.run(app, host=host, port=port)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@cli.command()
|
|
257
|
+
def formats():
|
|
258
|
+
"""Show supported input file types and expected folder structure."""
|
|
259
|
+
table = Table(title="Supported File Types")
|
|
260
|
+
table.add_column("Type", style="cyan", width=18)
|
|
261
|
+
table.add_column("Subfolder", style="green", width=12)
|
|
262
|
+
table.add_column("Format", style="yellow", width=10)
|
|
263
|
+
table.add_column("Description")
|
|
264
|
+
|
|
265
|
+
rows = [
|
|
266
|
+
("form16", "form16/", "PDF", "Form 16 from employer (Part A + Part B)"),
|
|
267
|
+
("ais", "it/", "JSON/PDF", "Annual Information Statement (encrypted)"),
|
|
268
|
+
("26as", "it/", "PDF/TXT", "Form 26AS — TDS credits"),
|
|
269
|
+
("etrade-gl", "etrade/", "XLSX", "E-Trade G&L Expanded — ESOP/ESPP sales"),
|
|
270
|
+
("etrade-benefit", "etrade/", "XLSX", "E-Trade BenefitHistory — grants, vests, Schedule FA"),
|
|
271
|
+
("employer-perq", "employer/", "PDF", "Employer perquisite workings"),
|
|
272
|
+
("broker-pl", "broker/", "XLSX", "Broker tax P&L — dividends, equity trades"),
|
|
273
|
+
("mf", "mf/", "XLSX", "Mutual fund capital gains report"),
|
|
274
|
+
("bank", "bank/", "PDF/CSV", "Bank statements (any Indian bank)"),
|
|
275
|
+
]
|
|
276
|
+
for r in rows:
|
|
277
|
+
table.add_row(*r)
|
|
278
|
+
console.print(table)
|
|
279
|
+
|
|
280
|
+
console.print("\n[bold]Expected folder structure:[/]")
|
|
281
|
+
console.print("""
|
|
282
|
+
<folder>/
|
|
283
|
+
├── config.yaml # PAN, DOB, FY, bank details (optional)
|
|
284
|
+
├── form16/ # Form 16 PDFs
|
|
285
|
+
├── etrade/ # E-Trade exports
|
|
286
|
+
├── employer/ # Employer perquisite docs
|
|
287
|
+
├── broker/ # Zerodha/Groww tax P&L
|
|
288
|
+
├── mf/ # Mutual fund reports
|
|
289
|
+
├── bank/ # Bank statements
|
|
290
|
+
└── it/ # AIS, TIS, 26AS, Form 168
|
|
291
|
+
""")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@cli.command()
|
|
295
|
+
@click.argument("folders", nargs=-1, required=True, type=click.Path(exists=True, file_okay=False))
|
|
296
|
+
@click.option("--output", default=None, type=click.Path(), help="Output file path for comparison report.")
|
|
297
|
+
def compare(folders, output):
|
|
298
|
+
"""Compare tax across multiple assessment years.
|
|
299
|
+
|
|
300
|
+
Pass 2 or more AY folders to see salary, CG, deductions, and tax trends.
|
|
301
|
+
|
|
302
|
+
Example: itr-tool compare IT/2024-25 IT/2025-26 IT/2026-27
|
|
303
|
+
"""
|
|
304
|
+
from itr_tool.output.yoy_report import generate_yoy_report
|
|
305
|
+
folder_paths = [Path(f) for f in folders]
|
|
306
|
+
out_path = Path(output) if output else None
|
|
307
|
+
console.print(Panel.fit(
|
|
308
|
+
f"[bold blue]Year-over-Year Comparison[/] — {len(folder_paths)} years",
|
|
309
|
+
))
|
|
310
|
+
generate_yoy_report(folder_paths, output_path=out_path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
cli()
|
itr_tool/config.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Configuration loader — reads config.yaml + FY rule packs."""
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from itr_tool.models.personal import PersonalDetails, BankAccount, AccountType
|
|
10
|
+
from itr_tool.models.deductions import (
|
|
11
|
+
AllDeductions, Deduction80C, Deduction80D, Deduction80CCD, DeductionHRA,
|
|
12
|
+
)
|
|
13
|
+
from itr_tool.models.advance_tax import TaxPaymentEntry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RULES_DIR = Path(__file__).parent / "rules"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PriorYearLosses(BaseModel):
|
|
20
|
+
"""Losses brought forward from prior assessment year."""
|
|
21
|
+
hp_loss_bf: float = 0.0
|
|
22
|
+
stcg_loss_bf: float = 0.0
|
|
23
|
+
ltcg_loss_bf: float = 0.0
|
|
24
|
+
source_ay: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HousePropertyConfig(BaseModel):
|
|
28
|
+
"""House property income configuration."""
|
|
29
|
+
property_type: str = "self_occupied" # self_occupied, let_out
|
|
30
|
+
rent_received: float = 0.0
|
|
31
|
+
municipal_taxes: float = 0.0
|
|
32
|
+
loan_interest: float = 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PropertyTxnConfig(BaseModel):
|
|
36
|
+
"""Property sale transaction for capital gains."""
|
|
37
|
+
description: str = ""
|
|
38
|
+
purchase_date: str = ""
|
|
39
|
+
sale_date: str = ""
|
|
40
|
+
purchase_cost: float = 0.0
|
|
41
|
+
improvement_cost: float = 0.0
|
|
42
|
+
sale_consideration: float = 0.0
|
|
43
|
+
stamp_duty_value: float = 0.0
|
|
44
|
+
tds_buyer: float = 0.0
|
|
45
|
+
exemption_54: float = 0.0
|
|
46
|
+
exemption_54f: float = 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HRAConfig(BaseModel):
|
|
50
|
+
"""HRA exemption inputs."""
|
|
51
|
+
rent_paid_annual: float = 0.0
|
|
52
|
+
is_metro: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AppConfig(BaseModel):
|
|
56
|
+
"""Runtime configuration assembled from config.yaml + rule pack."""
|
|
57
|
+
personal: PersonalDetails = Field(default_factory=PersonalDetails)
|
|
58
|
+
input_folder: Path = Path(".")
|
|
59
|
+
output_folder: Path = Path("./output")
|
|
60
|
+
regime: str = "both" # "old", "new", "both"
|
|
61
|
+
ais_password: str = ""
|
|
62
|
+
rules: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
prior_year_losses: PriorYearLosses = Field(default_factory=PriorYearLosses)
|
|
64
|
+
deductions: AllDeductions = Field(default_factory=AllDeductions)
|
|
65
|
+
hra: HRAConfig = Field(default_factory=HRAConfig)
|
|
66
|
+
house_properties: list[HousePropertyConfig] = Field(default_factory=list)
|
|
67
|
+
property_transactions: list[PropertyTxnConfig] = Field(default_factory=list)
|
|
68
|
+
advance_tax_entries: list[TaxPaymentEntry] = Field(default_factory=list)
|
|
69
|
+
self_assessment_tax_entries: list[TaxPaymentEntry] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_rules(fy: str = "2025-26") -> dict[str, Any]:
|
|
73
|
+
"""Load FY-specific tax rules from JSON pack."""
|
|
74
|
+
fname = f"fy{fy.replace('-', '_')}.json"
|
|
75
|
+
path = RULES_DIR / fname
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise FileNotFoundError(f"Rule pack not found: {path}")
|
|
78
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
79
|
+
return json.load(f)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(
|
|
83
|
+
folder: Path,
|
|
84
|
+
fy: Optional[str] = None,
|
|
85
|
+
regime: str = "both",
|
|
86
|
+
ais_password: str = "",
|
|
87
|
+
) -> AppConfig:
|
|
88
|
+
"""Load config.yaml from folder + merge with defaults and FY rules."""
|
|
89
|
+
config_path = folder / "config.yaml"
|
|
90
|
+
raw: dict[str, Any] = {}
|
|
91
|
+
if config_path.exists():
|
|
92
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
93
|
+
raw = yaml.safe_load(f) or {}
|
|
94
|
+
|
|
95
|
+
# Resolve FY
|
|
96
|
+
financial_year = fy or raw.get("financial_year", "2025-26")
|
|
97
|
+
assessment_year = raw.get("assessment_year", "")
|
|
98
|
+
if not assessment_year:
|
|
99
|
+
# derive: FY 2025-26 → AY 2026-27
|
|
100
|
+
parts = financial_year.split("-")
|
|
101
|
+
ay_start = int(parts[0]) + 1
|
|
102
|
+
ay_end = int(parts[1]) + 1
|
|
103
|
+
assessment_year = f"{ay_start}-{ay_end:02d}"
|
|
104
|
+
|
|
105
|
+
# Personal details
|
|
106
|
+
dob = raw.get("dob")
|
|
107
|
+
personal = PersonalDetails(
|
|
108
|
+
pan=raw.get("pan", ""),
|
|
109
|
+
name=raw.get("name", ""),
|
|
110
|
+
father_name=raw.get("father_name", ""),
|
|
111
|
+
date_of_birth=dob if dob else None,
|
|
112
|
+
address=raw.get("address", ""),
|
|
113
|
+
city=raw.get("city", ""),
|
|
114
|
+
state=raw.get("state", ""),
|
|
115
|
+
pincode=raw.get("pincode", ""),
|
|
116
|
+
email=raw.get("email", ""),
|
|
117
|
+
phone=raw.get("phone", ""),
|
|
118
|
+
aadhaar=raw.get("aadhaar", ""),
|
|
119
|
+
financial_year=financial_year,
|
|
120
|
+
assessment_year=assessment_year,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Bank accounts
|
|
124
|
+
for b in raw.get("banks", []):
|
|
125
|
+
personal.bank_accounts.append(BankAccount(
|
|
126
|
+
name=b.get("name", ""),
|
|
127
|
+
ifsc=b.get("ifsc", ""),
|
|
128
|
+
account_number=str(b.get("account", "")),
|
|
129
|
+
account_type=AccountType(b.get("type", "savings")),
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
# Rules
|
|
133
|
+
rules = load_rules(financial_year)
|
|
134
|
+
|
|
135
|
+
# AIS password
|
|
136
|
+
pwd = ais_password or raw.get("ais_password", "")
|
|
137
|
+
|
|
138
|
+
# Prior year losses
|
|
139
|
+
pyl_raw = raw.get("prior_year_losses", {}) or {}
|
|
140
|
+
prior_losses = PriorYearLosses(
|
|
141
|
+
hp_loss_bf=pyl_raw.get("hp_loss_bf", 0) or 0,
|
|
142
|
+
stcg_loss_bf=pyl_raw.get("stcg_loss_bf", 0) or 0,
|
|
143
|
+
ltcg_loss_bf=pyl_raw.get("ltcg_loss_bf", 0) or 0,
|
|
144
|
+
source_ay=str(pyl_raw.get("source_ay", "")),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Deductions from config
|
|
148
|
+
ded_raw = raw.get("deductions", {}) or {}
|
|
149
|
+
d80c_raw = ded_raw.get("80c", {}) or {}
|
|
150
|
+
d80d_raw = ded_raw.get("80d", {}) or {}
|
|
151
|
+
d80ccd_raw = ded_raw.get("80ccd", {}) or {}
|
|
152
|
+
deductions = AllDeductions(
|
|
153
|
+
d80c=Deduction80C(
|
|
154
|
+
epf=d80c_raw.get("epf", 0) or 0,
|
|
155
|
+
ppf=d80c_raw.get("ppf", 0) or 0,
|
|
156
|
+
elss=d80c_raw.get("elss", 0) or 0,
|
|
157
|
+
lic=d80c_raw.get("lic", 0) or 0,
|
|
158
|
+
tuition_fees=d80c_raw.get("tuition_fees", 0) or 0,
|
|
159
|
+
nsc=d80c_raw.get("nsc", 0) or 0,
|
|
160
|
+
principal_repayment=d80c_raw.get("housing_principal", 0) or 0,
|
|
161
|
+
fd_5year=d80c_raw.get("fd_5year", 0) or 0,
|
|
162
|
+
other=d80c_raw.get("other", 0) or 0,
|
|
163
|
+
),
|
|
164
|
+
d80d=Deduction80D(
|
|
165
|
+
self_premium=d80d_raw.get("self_premium", 0) or 0,
|
|
166
|
+
parents_premium=d80d_raw.get("parents_premium", 0) or 0,
|
|
167
|
+
preventive_checkup=d80d_raw.get("preventive_checkup", 0) or 0,
|
|
168
|
+
),
|
|
169
|
+
d80ccd=Deduction80CCD(
|
|
170
|
+
employee_nps_80ccd1=d80ccd_raw.get("employee_nps", 0) or 0,
|
|
171
|
+
additional_nps_80ccd1b=d80ccd_raw.get("additional_nps", 0) or 0,
|
|
172
|
+
employer_nps_80ccd2=d80ccd_raw.get("employer_nps", 0) or 0,
|
|
173
|
+
),
|
|
174
|
+
d80e=ded_raw.get("80e", 0) or 0,
|
|
175
|
+
d80g=ded_raw.get("80g", 0) or 0,
|
|
176
|
+
d80gg=ded_raw.get("80gg", 0) or 0,
|
|
177
|
+
)
|
|
178
|
+
# Compute 80C eligible (capped at 1.5L)
|
|
179
|
+
d80c = deductions.d80c
|
|
180
|
+
d80c.gross_total = (d80c.epf + d80c.ppf + d80c.elss + d80c.lic +
|
|
181
|
+
d80c.tuition_fees + d80c.nsc + d80c.principal_repayment +
|
|
182
|
+
d80c.fd_5year + d80c.other)
|
|
183
|
+
limit_80c = rules.get("deduction_limits", {}).get("80C", 150000)
|
|
184
|
+
d80c.eligible = min(d80c.gross_total, limit_80c)
|
|
185
|
+
# Compute 80D eligible
|
|
186
|
+
d80d = deductions.d80d
|
|
187
|
+
limit_self = rules.get("deduction_limits", {}).get("80D_self_below60", 25000)
|
|
188
|
+
limit_parents = rules.get("deduction_limits", {}).get("80D_parents_below60", 25000)
|
|
189
|
+
d80d.self_eligible = min(d80d.self_premium + min(d80d.preventive_checkup, 5000), limit_self)
|
|
190
|
+
d80d.parents_eligible = min(d80d.parents_premium, limit_parents)
|
|
191
|
+
d80d.total_eligible = d80d.self_eligible + d80d.parents_eligible
|
|
192
|
+
# Compute 80CCD eligible
|
|
193
|
+
d80ccd = deductions.d80ccd
|
|
194
|
+
d80ccd.eligible_80ccd1b = min(d80ccd.additional_nps_80ccd1b, 50000)
|
|
195
|
+
d80ccd.eligible_80ccd2 = d80ccd.employer_nps_80ccd2 # no limit
|
|
196
|
+
|
|
197
|
+
# HRA config
|
|
198
|
+
hra_raw = raw.get("hra", {}) or {}
|
|
199
|
+
hra_cfg = HRAConfig(
|
|
200
|
+
rent_paid_annual=hra_raw.get("rent_paid_annual", 0) or 0,
|
|
201
|
+
is_metro=hra_raw.get("is_metro", False),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# House property
|
|
205
|
+
hp_list = []
|
|
206
|
+
for hp in (raw.get("house_property") or []):
|
|
207
|
+
if isinstance(hp, dict):
|
|
208
|
+
hp_list.append(HousePropertyConfig(
|
|
209
|
+
property_type=hp.get("type", "self_occupied"),
|
|
210
|
+
rent_received=hp.get("rent_received", 0) or 0,
|
|
211
|
+
municipal_taxes=hp.get("municipal_taxes", 0) or 0,
|
|
212
|
+
loan_interest=hp.get("loan_interest", 0) or 0,
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
# Property transactions
|
|
216
|
+
prop_txns = []
|
|
217
|
+
for pt in (raw.get("property_transactions") or []):
|
|
218
|
+
if isinstance(pt, dict):
|
|
219
|
+
prop_txns.append(PropertyTxnConfig(
|
|
220
|
+
description=pt.get("description", ""),
|
|
221
|
+
purchase_date=str(pt.get("purchase_date", "")),
|
|
222
|
+
sale_date=str(pt.get("sale_date", "")),
|
|
223
|
+
purchase_cost=pt.get("purchase_cost", 0) or 0,
|
|
224
|
+
improvement_cost=pt.get("improvement_cost", 0) or 0,
|
|
225
|
+
sale_consideration=pt.get("sale_consideration", 0) or 0,
|
|
226
|
+
stamp_duty_value=pt.get("stamp_duty_value", 0) or 0,
|
|
227
|
+
tds_buyer=pt.get("tds_buyer", 0) or 0,
|
|
228
|
+
exemption_54=pt.get("exemption_54", 0) or 0,
|
|
229
|
+
exemption_54f=pt.get("exemption_54f", 0) or 0,
|
|
230
|
+
))
|
|
231
|
+
|
|
232
|
+
# Advance tax entries
|
|
233
|
+
adv_tax_entries = []
|
|
234
|
+
for at in (raw.get("advance_tax") or []):
|
|
235
|
+
if isinstance(at, dict):
|
|
236
|
+
adv_tax_entries.append(TaxPaymentEntry(
|
|
237
|
+
date=str(at.get("date", "")),
|
|
238
|
+
amount=at.get("amount", 0) or 0,
|
|
239
|
+
bsr_code=str(at.get("bsr_code", "")),
|
|
240
|
+
challan_no=str(at.get("challan_no", "")),
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
# Self-assessment tax entries
|
|
244
|
+
sat_entries = []
|
|
245
|
+
for st in (raw.get("self_assessment_tax") or []):
|
|
246
|
+
if isinstance(st, dict):
|
|
247
|
+
sat_entries.append(TaxPaymentEntry(
|
|
248
|
+
date=str(st.get("date", "")),
|
|
249
|
+
amount=st.get("amount", 0) or 0,
|
|
250
|
+
bsr_code=str(st.get("bsr_code", "")),
|
|
251
|
+
challan_no=str(st.get("challan_no", "")),
|
|
252
|
+
))
|
|
253
|
+
|
|
254
|
+
# Output folder
|
|
255
|
+
output = folder / "output"
|
|
256
|
+
|
|
257
|
+
return AppConfig(
|
|
258
|
+
personal=personal,
|
|
259
|
+
input_folder=folder,
|
|
260
|
+
output_folder=output,
|
|
261
|
+
regime=regime,
|
|
262
|
+
ais_password=pwd,
|
|
263
|
+
rules=rules,
|
|
264
|
+
prior_year_losses=prior_losses,
|
|
265
|
+
deductions=deductions,
|
|
266
|
+
hra=hra_cfg,
|
|
267
|
+
house_properties=hp_list,
|
|
268
|
+
property_transactions=prop_txns,
|
|
269
|
+
advance_tax_entries=adv_tax_entries,
|
|
270
|
+
self_assessment_tax_entries=sat_entries,
|
|
271
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Config version migration — detects old config.yaml and suggests new fields."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
CURRENT_VERSION = 2
|
|
8
|
+
|
|
9
|
+
NEW_FIELDS_V2 = {
|
|
10
|
+
"advance_tax": "# advance_tax:\n# - date: '2025-09-15'\n# amount: 50000\n# bsr_code: '0123456'\n# challan_no: '12345'",
|
|
11
|
+
"self_assessment_tax": "# self_assessment_tax:\n# - date: '2026-07-15'\n# amount: 20000",
|
|
12
|
+
"config_version": "config_version: 2",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_and_migrate(config_path: Path) -> list[str]:
|
|
17
|
+
"""Check config version and return list of migration suggestions.
|
|
18
|
+
|
|
19
|
+
Does NOT modify the file — just reports what's missing.
|
|
20
|
+
"""
|
|
21
|
+
if not config_path.exists():
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
text = config_path.read_text(encoding="utf-8")
|
|
25
|
+
suggestions = []
|
|
26
|
+
|
|
27
|
+
# Check for version field
|
|
28
|
+
if "config_version" not in text:
|
|
29
|
+
suggestions.append(
|
|
30
|
+
"Add 'config_version: 2' to track schema version"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Check for v2 fields
|
|
34
|
+
if "advance_tax" not in text:
|
|
35
|
+
suggestions.append(
|
|
36
|
+
"New field available: 'advance_tax' — list of advance tax payments (date, amount, BSR code)"
|
|
37
|
+
)
|
|
38
|
+
if "self_assessment_tax" not in text:
|
|
39
|
+
suggestions.append(
|
|
40
|
+
"New field available: 'self_assessment_tax' — list of SAT payments"
|
|
41
|
+
)
|
|
42
|
+
if "prior_year_losses" not in text:
|
|
43
|
+
suggestions.append(
|
|
44
|
+
"New field available: 'prior_year_losses' — B/F STCG/LTCG/HP losses from prior AY"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return suggestions
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def print_migration_info(config_path: Path):
|
|
51
|
+
"""Print migration suggestions to console."""
|
|
52
|
+
suggestions = check_and_migrate(config_path)
|
|
53
|
+
if suggestions:
|
|
54
|
+
console.print(f"\n [yellow]Config upgrade suggestions ({config_path.name}):[/]")
|
|
55
|
+
for s in suggestions:
|
|
56
|
+
console.print(f" • {s}")
|
|
57
|
+
console.print(f" [dim]Run 'itr-tool init {config_path.parent}' to update interactively.[/]\n")
|