diff --git a/docs/us-cpa.md b/docs/us-cpa.md index b5416dd..66dc9eb 100644 --- a/docs/us-cpa.md +++ b/docs/us-cpa.md @@ -2,6 +2,21 @@ `us-cpa` is a Python CLI plus OpenClaw skill wrapper for U.S. federal individual tax work. +## Standalone package usage + +From `skills/us-cpa/`: + +```bash +pip install -e .[dev] +us-cpa --help +``` + +Without installing, the repo-local wrapper works directly: + +```bash +skills/us-cpa/scripts/us-cpa --help +``` + ## Current Milestone Current implementation now includes: @@ -13,10 +28,9 @@ Current implementation now includes: - case-folder intake and conflict-stop handling - question workflow with conversation and memo output - prepare workflow for the current supported 1040 subset +- review workflow with findings-first output - e-file-ready draft export payload generation -Review logic and broader form coverage are still pending. - ## CLI Surface ```bash diff --git a/skills/us-cpa/README.md b/skills/us-cpa/README.md new file mode 100644 index 0000000..54a3140 --- /dev/null +++ b/skills/us-cpa/README.md @@ -0,0 +1,45 @@ +# us-cpa package + +Standalone Python CLI package for the `us-cpa` skill. + +## Install + +From `skills/us-cpa/`: + +```bash +pip install -e .[dev] +``` + +## Run + +Installed entry point: + +```bash +us-cpa --help +``` + +Repo-local wrapper without installation: + +```bash +scripts/us-cpa --help +``` + +Module execution: + +```bash +python3 -m us_cpa.cli --help +``` + +## Tests + +From `skills/us-cpa/`: + +```bash +PYTHONPATH=src python3 -m unittest +``` + +Or with the dev extra installed: + +```bash +python -m unittest +``` diff --git a/skills/us-cpa/pyproject.toml b/skills/us-cpa/pyproject.toml index eb9a667..a448842 100644 --- a/skills/us-cpa/pyproject.toml +++ b/skills/us-cpa/pyproject.toml @@ -7,7 +7,15 @@ name = "us-cpa" version = "0.1.0" description = "US federal individual tax workflow CLI for questions, preparation, and review." requires-python = ">=3.9" -dependencies = [] +dependencies = [ + "pypdf>=5.0.0", + "reportlab>=4.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", +] [project.scripts] us-cpa = "us_cpa.cli:main" diff --git a/skills/us-cpa/src/us_cpa/questions.py b/skills/us-cpa/src/us_cpa/questions.py index 3c65a43..1ee9554 100644 --- a/skills/us-cpa/src/us_cpa/questions.py +++ b/skills/us-cpa/src/us_cpa/questions.py @@ -32,6 +32,13 @@ TOPIC_RULES = [ ] +RISK_BY_CONFIDENCE = { + "high": "low", + "medium": "medium", + "low": "high", +} + + def _normalize_question(question: str) -> str: return question.strip().lower() @@ -98,6 +105,7 @@ class QuestionEngine: "authorities": authorities, "conclusion": {"answer": answer, "summary": summary}, "confidence": rule["confidence"], + "riskLevel": RISK_BY_CONFIDENCE[rule["confidence"]], "followUpQuestions": [], "primaryLawRequired": False, } @@ -115,6 +123,7 @@ class QuestionEngine: "summary": "This question needs primary-law analysis before a reliable answer can be given.", }, "confidence": "low", + "riskLevel": "high", "followUpQuestions": [ "What facts drive the section-level issue?", "Is there an existing return position or drafted treatment to review?", @@ -125,6 +134,9 @@ class QuestionEngine: def render_analysis(analysis: dict[str, Any]) -> str: lines = [analysis["conclusion"]["summary"]] + lines.append( + f"Confidence: {analysis['confidence']}. Risk: {analysis['riskLevel']}." + ) if analysis["factsUsed"]: facts = ", ".join(f"{item['field']}={item['value']}" for item in analysis["factsUsed"]) lines.append(f"Facts used: {facts}.") @@ -160,6 +172,8 @@ def render_memo(analysis: dict[str, Any]) -> str: "", "## Analysis", analysis["conclusion"]["summary"], + f"Confidence: {analysis['confidence']}", + f"Risk level: {analysis['riskLevel']}", "", "## Conclusion", analysis["conclusion"]["answer"], diff --git a/skills/us-cpa/src/us_cpa/returns.py b/skills/us-cpa/src/us_cpa/returns.py index c952e6a..3af4d10 100644 --- a/skills/us-cpa/src/us_cpa/returns.py +++ b/skills/us-cpa/src/us_cpa/returns.py @@ -1,45 +1,8 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any - -STANDARD_DEDUCTION_2025 = { - "single": 15750.0, - "married_filing_jointly": 31500.0, - "head_of_household": 23625.0, -} - - -TAX_BRACKETS_2025 = { - "single": [ - (11925.0, 0.10), - (48475.0, 0.12), - (103350.0, 0.22), - (197300.0, 0.24), - (250525.0, 0.32), - (626350.0, 0.35), - (float("inf"), 0.37), - ], - "married_filing_jointly": [ - (23850.0, 0.10), - (96950.0, 0.12), - (206700.0, 0.22), - (394600.0, 0.24), - (501050.0, 0.32), - (751600.0, 0.35), - (float("inf"), 0.37), - ], - "head_of_household": [ - (17000.0, 0.10), - (64850.0, 0.12), - (103350.0, 0.22), - (197300.0, 0.24), - (250500.0, 0.32), - (626350.0, 0.35), - (float("inf"), 0.37), - ], -} +from us_cpa.tax_years import tax_year_rules def _as_float(value: Any) -> float: @@ -48,9 +11,9 @@ def _as_float(value: Any) -> float: return float(value) -def tax_on_ordinary_income(amount: float, filing_status: str) -> float: +def tax_on_ordinary_income(amount: float, filing_status: str, tax_year: int) -> float: taxable = max(0.0, amount) - brackets = TAX_BRACKETS_2025[filing_status] + brackets = tax_year_rules(tax_year)["ordinaryIncomeBrackets"][filing_status] lower = 0.0 tax = 0.0 for upper, rate in brackets: @@ -72,6 +35,7 @@ def resolve_required_forms(normalized: dict[str, Any]) -> list[str]: def normalize_case_facts(facts: dict[str, Any], tax_year: int) -> dict[str, Any]: + rules = tax_year_rules(tax_year) filing_status = facts.get("filingStatus", "single") wages = _as_float(facts.get("wages")) interest = _as_float(facts.get("taxableInterest")) @@ -79,9 +43,9 @@ def normalize_case_facts(facts: dict[str, Any], tax_year: int) -> dict[str, Any] withholding = _as_float(facts.get("federalWithholding")) adjusted_gross_income = wages + interest + business_income - standard_deduction = STANDARD_DEDUCTION_2025[filing_status] + standard_deduction = rules["standardDeduction"][filing_status] taxable_income = max(0.0, adjusted_gross_income - standard_deduction) - income_tax = tax_on_ordinary_income(taxable_income, filing_status) + income_tax = tax_on_ordinary_income(taxable_income, filing_status, tax_year) self_employment_tax = round(max(0.0, business_income) * 0.9235 * 0.153, 2) total_tax = round(income_tax + self_employment_tax, 2) total_payments = withholding diff --git a/skills/us-cpa/src/us_cpa/tax_years.py b/skills/us-cpa/src/us_cpa/tax_years.py new file mode 100644 index 0000000..aef9b4f --- /dev/null +++ b/skills/us-cpa/src/us_cpa/tax_years.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import Any + + +TAX_YEAR_DATA: dict[int, dict[str, Any]] = { + 2024: { + "standardDeduction": { + "single": 14600.0, + "married_filing_jointly": 29200.0, + "head_of_household": 21900.0, + }, + "ordinaryIncomeBrackets": { + "single": [ + (11600.0, 0.10), + (47150.0, 0.12), + (100525.0, 0.22), + (191950.0, 0.24), + (243725.0, 0.32), + (609350.0, 0.35), + (float("inf"), 0.37), + ], + "married_filing_jointly": [ + (23200.0, 0.10), + (94300.0, 0.12), + (201050.0, 0.22), + (383900.0, 0.24), + (487450.0, 0.32), + (731200.0, 0.35), + (float("inf"), 0.37), + ], + "head_of_household": [ + (16550.0, 0.10), + (63100.0, 0.12), + (100500.0, 0.22), + (191950.0, 0.24), + (243700.0, 0.32), + (609350.0, 0.35), + (float("inf"), 0.37), + ], + }, + }, + 2025: { + "standardDeduction": { + "single": 15750.0, + "married_filing_jointly": 31500.0, + "head_of_household": 23625.0, + }, + "ordinaryIncomeBrackets": { + "single": [ + (11925.0, 0.10), + (48475.0, 0.12), + (103350.0, 0.22), + (197300.0, 0.24), + (250525.0, 0.32), + (626350.0, 0.35), + (float("inf"), 0.37), + ], + "married_filing_jointly": [ + (23850.0, 0.10), + (96950.0, 0.12), + (206700.0, 0.22), + (394600.0, 0.24), + (501050.0, 0.32), + (751600.0, 0.35), + (float("inf"), 0.37), + ], + "head_of_household": [ + (17000.0, 0.10), + (64850.0, 0.12), + (103350.0, 0.22), + (197300.0, 0.24), + (250500.0, 0.32), + (626350.0, 0.35), + (float("inf"), 0.37), + ], + }, + }, +} + + +def supported_tax_years() -> list[int]: + return sorted(TAX_YEAR_DATA) + + +def tax_year_rules(tax_year: int) -> dict[str, Any]: + try: + return TAX_YEAR_DATA[tax_year] + except KeyError as exc: + years = ", ".join(str(year) for year in supported_tax_years()) + raise ValueError( + f"Unsupported tax year {tax_year}. Supported tax years: {years}." + ) from exc diff --git a/skills/us-cpa/tests/test_cli.py b/skills/us-cpa/tests/test_cli.py index b8e7a4f..da341f0 100644 --- a/skills/us-cpa/tests/test_cli.py +++ b/skills/us-cpa/tests/test_cli.py @@ -13,15 +13,33 @@ SKILL_DIR = Path(__file__).resolve().parents[1] SRC_DIR = SKILL_DIR / "src" +def _pyproject_text() -> str: + return (SKILL_DIR / "pyproject.toml").read_text() + + class UsCpaCliSmokeTests(unittest.TestCase): def test_skill_scaffold_files_exist(self) -> None: self.assertTrue((SKILL_DIR / "SKILL.md").exists()) self.assertTrue((SKILL_DIR / "pyproject.toml").exists()) + self.assertTrue((SKILL_DIR / "README.md").exists()) self.assertTrue((SKILL_DIR / "scripts" / "us-cpa").exists()) self.assertTrue( (SKILL_DIR.parent.parent / "docs" / "us-cpa.md").exists() ) + def test_pyproject_declares_runtime_and_dev_dependencies(self) -> None: + pyproject = _pyproject_text() + self.assertIn('"pypdf>=', pyproject) + self.assertIn('"reportlab>=', pyproject) + self.assertIn("[project.optional-dependencies]", pyproject) + self.assertIn('"pytest>=', pyproject) + + def test_readme_documents_install_and_script_usage(self) -> None: + readme = (SKILL_DIR / "README.md").read_text() + self.assertIn("pip install -e .[dev]", readme) + self.assertIn("scripts/us-cpa", readme) + self.assertIn("python -m unittest", readme) + def test_fixture_directories_exist(self) -> None: fixtures_dir = SKILL_DIR / "tests" / "fixtures" for name in ("irs", "facts", "documents", "returns"): diff --git a/skills/us-cpa/tests/test_questions.py b/skills/us-cpa/tests/test_questions.py index b2fceef..7ab1d34 100644 --- a/skills/us-cpa/tests/test_questions.py +++ b/skills/us-cpa/tests/test_questions.py @@ -33,6 +33,7 @@ class QuestionEngineTests(unittest.TestCase): self.assertEqual(analysis["taxYear"], 2025) self.assertEqual(analysis["conclusion"]["answer"], "$15,750") self.assertEqual(analysis["confidence"], "high") + self.assertEqual(analysis["riskLevel"], "low") self.assertTrue(analysis["authorities"]) self.assertEqual(analysis["authorities"][0]["sourceClass"], "irs_instructions") @@ -47,6 +48,7 @@ class QuestionEngineTests(unittest.TestCase): ) self.assertEqual(analysis["confidence"], "low") + self.assertEqual(analysis["riskLevel"], "high") self.assertTrue(analysis["primaryLawRequired"]) self.assertIn("Internal Revenue Code", analysis["missingFacts"][0]) @@ -59,6 +61,7 @@ class QuestionEngineTests(unittest.TestCase): "authorities": [{"title": "Instructions for Form 1040 and Schedules 1-3"}], "conclusion": {"answer": "$15,750", "summary": "Single filers use a $15,750 standard deduction for tax year 2025."}, "confidence": "high", + "riskLevel": "low", "followUpQuestions": [], "primaryLawRequired": False, } diff --git a/skills/us-cpa/tests/test_returns.py b/skills/us-cpa/tests/test_returns.py index 2541e2c..ed5b66d 100644 --- a/skills/us-cpa/tests/test_returns.py +++ b/skills/us-cpa/tests/test_returns.py @@ -43,6 +43,13 @@ class ReturnModelTests(unittest.TestCase): def test_tax_bracket_calculation_uses_2025_single_rates(self) -> None: self.assertEqual(tax_on_ordinary_income(34350.0, "single"), 3883.5) + def test_tax_bracket_calculation_uses_selected_tax_year(self) -> None: + self.assertEqual(tax_on_ordinary_income(33650.0, "single", 2024), 3806.0) + + def test_normalize_case_facts_rejects_unsupported_tax_year(self) -> None: + with self.assertRaisesRegex(ValueError, "Unsupported tax year"): + normalize_case_facts({"filingStatus": "single"}, 2023) + if __name__ == "__main__": unittest.main()