From 686fd42027d677c2093148f4f877efa939cf9aae Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 4 Jun 2026 14:58:06 -0400 Subject: [PATCH] =?UTF-8?q?Bump=20policyengine-us=20to=20>=3D1.711.0=20and?= =?UTF-8?q?=20pin=20otherprop=E2=86=92NIIT=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit policyengine-us 1.711.0 includes PR #8564 which unwires the Utah Homeowner/Renter Relief credit from TC-40 refundable credits. Utah Code § 59-2A-205 (homeowner) and § 59-2A-305 (renter) administer the Circuit-Breaker credit on TC-90H and TC-90CB respectively — separate refund applications — and the TC-40 income-tax instructions for tax year 2025 carry no reference to either form. The fix removes a phantom $1,412 refundable credit that PE-US 1.710.6 was applying to qualifying UT seniors and pulls the credit back through the existing state_property_tax_credits aggregate (sptcr / v40). Also add a small regression test pinning the TAXSIM `otherprop` → PE-US `rental_income` routing introduced in PR #961: • IRC § 1411(c)(1)(A)(i) rents/royalties in NIIT base • Form 8960 Line 4a • TAXSIM-35 binary smoke test ($1M otherprop single → fiitax $353,188, NIIT $30,400) The QBID gate override in policyengine_runner.py is asserted by the fiitax target — auto-QBID on rental_income would knock the fiitax by roughly $170K, well outside the $50 tolerance. Co-Authored-By: Claude Opus 4.7 (1M context) --- changelog.d/bump-pe-us-and-niit-test.added.md | 1 + pyproject.toml | 2 +- tests/test_otherprop_niit.py | 132 ++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 changelog.d/bump-pe-us-and-niit-test.added.md create mode 100644 tests/test_otherprop_niit.py diff --git a/changelog.d/bump-pe-us-and-niit-test.added.md b/changelog.d/bump-pe-us-and-niit-test.added.md new file mode 100644 index 0000000..e3b6379 --- /dev/null +++ b/changelog.d/bump-pe-us-and-niit-test.added.md @@ -0,0 +1 @@ +Bump `policyengine-us` to `>=1.711.0` (picks up the upstream fix that unwires UT Homeowner/Renter Relief from TC-40 refundable credits, per Utah Tax Commission TC-90CB/TC-90H). Add integration test pinning the TAXSIM `otherprop` → PE-US `rental_income` + NIIT routing against IRC § 1411(c)(1)(A)(i) and the TAXSIM-35 binary. diff --git a/pyproject.toml b/pyproject.toml index 3e1d14c..086fdd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ - "policyengine-us>=1.552.0", + "policyengine-us>=1.711.0", "pandas", "PyYAML", "click", diff --git a/tests/test_otherprop_niit.py b/tests/test_otherprop_niit.py new file mode 100644 index 0000000..18bd954 --- /dev/null +++ b/tests/test_otherprop_niit.py @@ -0,0 +1,132 @@ +""" +Tests for TAXSIM `otherprop` → PE-US `rental_income` routing and NIIT. + +Legal anchors: +- IRC § 1411(c)(1)(A)(i): NIIT base includes "gross income from interest, + dividends, annuities, royalties, and rents, other than such income which + is derived in the ordinary course of a trade or business not described + in paragraph (2)". + https://www.law.cornell.edu/uscode/text/26/1411 +- Form 8960 Line 4a: "Income From Trades/Businesses/Farming, Rental Real + Estate, Royalties, Partnerships, S Corporations, and Trusts". + https://www.irs.gov/instructions/i8960 +- IRC § 199A(d)(1): QBID requires a "qualified trade or business"; + passive individual rental income generally does not qualify absent the + Reg § 1.199A-1(b)(14) safe harbor, which TAXSIM input cannot signal. + +TAXSIM-35 binary probe (single, age 45, otherprop=$1M, no other income, +year 2024) produces fiitax=$353,188 and niit=$30,400 (= 3.8% × ($1M − +$200K threshold)). PE-US must align via: + variable_mappings.yaml: otherprop → rental_income + policyengine_runner: rental_income_would_be_qualified=False for chunks + carrying otherprop, suppressing the QBID gate. + +These tests pin both legs so a future mapping edit can't silently +re-route otherprop away from the NIIT base or re-enable auto-QBID. +""" + +import pandas as pd +import pytest + +from policyengine_taxsim.runners.policyengine_runner import PolicyEngineRunner + + +def _single_otherprop_record(): + """Pure-otherprop probe: matches the TAXSIM-35 binary smoke test.""" + return pd.DataFrame( + { + "taxsimid": [1], + "year": 2024, + "state": [5], # CA + "mstat": 1, + "depx": 0, + "page": 45, + "sage": 0, + "pwages": 0.0, + "swages": 0.0, + "otherprop": [1_000_000.0], + "idtl": 2, + } + ) + + +def _mixed_record_no_otherprop(): + """Control: wages-only, no otherprop. Confirms NIIT only fires on + investment-type income above the threshold.""" + return pd.DataFrame( + { + "taxsimid": [2], + "year": 2024, + "state": [5], + "mstat": 1, + "depx": 0, + "page": 45, + "sage": 0, + "pwages": [1_000_000.0], + "swages": 0.0, + "otherprop": 0.0, + "idtl": 2, + } + ) + + +class TestOtherpropNIIT: + """Pin the otherprop → rental_income + NIIT behavior to TAXSIM-35.""" + + def test_otherprop_drives_niit_at_3_8_percent(self): + """ + $1M of pure `otherprop`, single, age 45 must trigger NIIT of + 3.8% × ($1M − $200K single threshold) = $30,400 — matching the + TAXSIM-35 binary and Form 8960 Line 4a. + """ + records = _single_otherprop_record() + runner = PolicyEngineRunner(records.copy(), logs=False) + result = runner.run(show_progress=False) + # NIIT column on TAXSIM v50 / PE niit output + niit = float(result["niit"].iloc[0]) + assert niit == pytest.approx(30_400, abs=10), ( + f"Expected NIIT=$30,400 (3.8% × $800K) for $1M otherprop single, " + f"got ${niit:.2f}. Likely cause: otherprop no longer routes to " + f"rental_income, or rental_income dropped out of " + f"gov.irs.investment.income.sources upstream." + ) + + def test_otherprop_does_not_trigger_qbid(self): + """ + TAXSIM does not apply § 199A QBID to `otherprop` — only the + explicit `pbusinc` input triggers QBID. PE-US's + `rental_income_would_be_qualified` defaults to True, so the + runner must override it to False when otherprop is present. + Verify by comparing fiitax against the TAXSIM-35 binary result + of $353,188 for a $1M otherprop single filer. + """ + records = _single_otherprop_record() + runner = PolicyEngineRunner(records.copy(), logs=False) + result = runner.run(show_progress=False) + fiitax = float(result["fiitax"].iloc[0]) + # TAXSIM-35 binary: $353,187.93. Allow $50 rounding because PE-US + # and TAXSIM use slightly different bracket-mid points. + assert fiitax == pytest.approx(353_188, abs=50), ( + f"Expected fiitax≈$353,188 to match TAXSIM-35 binary for $1M " + f"otherprop single. Got ${fiitax:.2f}. Spread of >$50 typically " + f"means PE applied QBID (~$170K reduction) on the rental_income, " + f"indicating the QBID gate override in policyengine_runner " + f"is not firing for this chunk." + ) + + def test_no_otherprop_no_niit_on_wages(self): + """ + Wages-only filer should not generate NIIT regardless of size. + Acts as a negative control on the routing: the override should + only suppress QBID for tax units carrying otherprop, and NIIT + should remain governed by investment-income sources. + """ + records = _mixed_record_no_otherprop() + runner = PolicyEngineRunner(records.copy(), logs=False) + result = runner.run(show_progress=False) + niit = float(result["niit"].iloc[0]) + assert niit == pytest.approx(0, abs=1), ( + f"Wages-only filer must not owe NIIT, got ${niit:.2f}. " + f"Could indicate otherprop's QBID override is leaking and " + f"reclassifying wages." + )