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.
Files changed (63) hide show
  1. itr_tool/__init__.py +2 -0
  2. itr_tool/cli.py +314 -0
  3. itr_tool/config.py +271 -0
  4. itr_tool/config_migrate.py +57 -0
  5. itr_tool/config_wizard.py +455 -0
  6. itr_tool/engine/__init__.py +0 -0
  7. itr_tool/engine/compute.py +310 -0
  8. itr_tool/engine/interest_234.py +138 -0
  9. itr_tool/engine/itr_form_selector.py +91 -0
  10. itr_tool/engine/reconcile.py +91 -0
  11. itr_tool/engine/regime_new.py +141 -0
  12. itr_tool/engine/regime_old.py +97 -0
  13. itr_tool/engine/section_89.py +67 -0
  14. itr_tool/models/__init__.py +0 -0
  15. itr_tool/models/advance_tax.py +11 -0
  16. itr_tool/models/capital_gains.py +136 -0
  17. itr_tool/models/computation.py +75 -0
  18. itr_tool/models/deductions.py +59 -0
  19. itr_tool/models/foreign_assets.py +40 -0
  20. itr_tool/models/other_income.py +22 -0
  21. itr_tool/models/personal.py +46 -0
  22. itr_tool/models/salary.py +56 -0
  23. itr_tool/output/__init__.py +0 -0
  24. itr_tool/output/itr1_json.py +204 -0
  25. itr_tool/output/itr2_json.py +400 -0
  26. itr_tool/output/pdf_report.py +189 -0
  27. itr_tool/output/sti_report.py +494 -0
  28. itr_tool/output/templates/computation.html +148 -0
  29. itr_tool/output/yoy_report.py +109 -0
  30. itr_tool/parsers/__init__.py +0 -0
  31. itr_tool/parsers/ais.py +311 -0
  32. itr_tool/parsers/ais_pdf.py +148 -0
  33. itr_tool/parsers/bank_statement.py +226 -0
  34. itr_tool/parsers/dispatcher.py +371 -0
  35. itr_tool/parsers/employer_perq.py +127 -0
  36. itr_tool/parsers/etrade_benefit.py +7 -0
  37. itr_tool/parsers/etrade_gl.py +186 -0
  38. itr_tool/parsers/form16.py +230 -0
  39. itr_tool/parsers/form26as.py +250 -0
  40. itr_tool/parsers/mf_capital_gains.py +55 -0
  41. itr_tool/parsers/password_wizard.py +189 -0
  42. itr_tool/parsers/rates.py +11 -0
  43. itr_tool/parsers/sbi_ttbr.py +103 -0
  44. itr_tool/parsers/tis.py +108 -0
  45. itr_tool/parsers/vestwise.py +2336 -0
  46. itr_tool/parsers/vestwise_data/SBI_REFERENCE_RATES_USD.csv +1601 -0
  47. itr_tool/parsers/vestwise_data/sale_price_overrides.csv +37 -0
  48. itr_tool/parsers/zerodha.py +93 -0
  49. itr_tool/rules/__init__.py +0 -0
  50. itr_tool/rules/fy2020_21.json +119 -0
  51. itr_tool/rules/fy2021_22.json +120 -0
  52. itr_tool/rules/fy2022_23.json +132 -0
  53. itr_tool/rules/fy2023_24.json +133 -0
  54. itr_tool/rules/fy2024_25.json +156 -0
  55. itr_tool/rules/fy2025_26.json +150 -0
  56. itr_tool/web/__init__.py +0 -0
  57. itr_tool/web/server.py +347 -0
  58. itr_tool-1.0.0.dist-info/METADATA +548 -0
  59. itr_tool-1.0.0.dist-info/RECORD +63 -0
  60. itr_tool-1.0.0.dist-info/WHEEL +5 -0
  61. itr_tool-1.0.0.dist-info/entry_points.txt +2 -0
  62. itr_tool-1.0.0.dist-info/licenses/LICENSE +21 -0
  63. itr_tool-1.0.0.dist-info/top_level.txt +1 -0
itr_tool/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """itr-tool: Offline Indian Income Tax computation tool."""
2
+ __version__ = "1.0.0"
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")