From 081ccb991c0bcb81e001306282f579e02a5d0c39 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 21:15:18 -0400 Subject: [PATCH 1/4] Net the 2026 section 68 taxable-income leg of non-itemized deductions Section 68(a)(2), as rewritten by P.L. 119-21 sec. 70111(a), measures "taxable income ... (determined without regard to this section and increased by such amount of itemized deductions)". For an itemizer that is AGI less exemptions and every deduction 63(b) allows besides itemized deductions (199A, 224, 225, 151(d)(5)(C), 163(h)(4)). The reduction used AGI minus exemptions, overstating the base for filers with a QBI deduction. - New taxable_income_before_itemized_deductions gives that amount; the reduction and Idaho's recomputation of it use it. - Section 199A(e)(1), as amended by sec. 70111(b), computes taxable income without regard to section 68, so taxable_income_less_qbid deducts itemized deductions before the limitation from 2026. This also keeps the reduction -> QBID -> reduction chain acyclic. - tax_unit_itemizes reads itemized deductions from the itemizing branch, since the QBI deduction (and so the limitation) depends on the choice. Fixes #9170 Co-Authored-By: Claude Opus 5.5 --- .../fix-68-taxable-income-leg.fixed.md | 1 + ...zed_taxable_income_deductions_reduction.py | 9 +++- ...xable_income_before_itemized_deductions.py | 42 +++++++++++++++++++ .../taxable_income_less_qbid.py | 15 ++++++- .../deductions/tax_unit_itemizes.py | 17 +++++++- .../deductions/id_itemized_deductions.py | 7 ++-- 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fix-68-taxable-income-leg.fixed.md create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.py diff --git a/changelog.d/fix-68-taxable-income-leg.fixed.md b/changelog.d/fix-68-taxable-income-leg.fixed.md new file mode 100644 index 00000000000..97ad834dccb --- /dev/null +++ b/changelog.d/fix-68-taxable-income-leg.fixed.md @@ -0,0 +1 @@ +From 2026, the section 68 limitation on itemized deductions measures taxable income net of the deductions other than itemized deductions, such as the qualified business income deduction, and the qualified business income deduction's taxable income ignores section 68. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py index 05d2de5a2d1..8598de8fe02 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.py @@ -31,8 +31,13 @@ def formula(tax_unit, period, parameters): top_rate_threshold = parameters( period ).gov.irs.income.bracket.thresholds["6"][filing_status] - exemptions = tax_unit("exemptions", period) - taxable_income = max_(0, agi - exemptions) + # Section 68(a)(2): taxable income determined without regard + # to section 68 and increased by the itemized deductions, that + # is, net of every other deduction, such as the qualified + # business income deduction. + taxable_income = tax_unit( + "taxable_income_before_itemized_deductions", period + ) taxable_income_excess = max_(0, taxable_income - top_rate_threshold) total_itemized_deductions = tax_unit( "total_itemized_taxable_income_deductions", period diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.py new file mode 100644 index 00000000000..8d30e3f22d0 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class taxable_income_before_itemized_deductions(Variable): + value_type = float + entity = TaxUnit + label = "Taxable income before itemized deductions" + unit = USD + documentation = ( + "Adjusted gross income less personal exemptions and the deductions an " + "itemizer takes besides itemized deductions: those 26 U.S.C. 63(b) " + "allows whether or not the filer itemizes, which 63(d) keeps out of " + "itemized deductions. This is the taxable income of section 68(a)(2), " + "determined without regard to section 68 and increased by the " + "itemized deductions. It is not floored at zero." + ) + definition_period = YEAR + reference = ( + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section63&num=0&edition=prelim", + "https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section68&num=0&edition=prelim", + # P.L. 119-21, sec. 70111(a), rewrote section 68 from 2026. + "https://www.govinfo.gov/content/pkg/PLAW-119publ21/html/PLAW-119publ21.htm", + ) + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.deductions + agi = tax_unit("adjusted_gross_income", period) + exemptions = tax_unit("exemptions", period) + # Section 68(a)(2) adds the itemized deductions back to taxable + # income, so neither they nor wagering losses (a section 165(d) + # itemized deduction, listed apart from the other itemized + # deductions) reduce this amount. Everything else an itemizer + # deducts does: the section 199A, 224 and 225 deductions, the + # section 151(d)(5)(C) senior deduction and qualified passenger + # vehicle loan interest. + itemized = ["itemized_taxable_income_deductions", "wagering_losses_deduction"] + other_deductions = [ + deduction + for deduction in p.deductions_if_itemizing + if deduction not in itemized + ] + return agi - exemptions - add(tax_unit, period, other_deductions) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/taxable_income_less_qbid.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/taxable_income_less_qbid.py index 44becdc0536..ae7696cdf1d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/taxable_income_less_qbid.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/taxable_income_less_qbid.py @@ -7,12 +7,25 @@ class taxable_income_less_qbid(Variable): label = "Taxable income (not considering QBID)" unit = USD definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/199A#e_1", + # P.L. 119-21, sec. 70111(b)(1), adds "without regard to section 68" + # to section 199A(e)(1) from 2026. + "https://www.govinfo.gov/content/pkg/PLAW-119publ21/html/PLAW-119publ21.htm", + ) def formula(tax_unit, period, parameters): agi = tax_unit("adjusted_gross_income", period) p = parameters(period).gov.irs.deductions + # Section 199A(e)(1) computes taxable income without regard to + # section 68 once P.L. 119-21 rewrites section 68, so itemizers + # deduct their itemized deductions before that limitation. + if p.itemized.limitation.obbb.applies: + itemized = "total_itemized_taxable_income_deductions" + else: + itemized = "itemized_taxable_income_deductions" ded_if_itemizing = [ - deduction + itemized if deduction == "itemized_taxable_income_deductions" else deduction for deduction in p.deductions_if_itemizing if deduction != "qualified_business_income_deduction" ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py index 2c36139155e..d5eefb89b3a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py @@ -10,8 +10,23 @@ class tax_unit_itemizes(Variable): definition_period = YEAR def formula(tax_unit, period, parameters): + # Calculated before branching, so the itemizing branch inherits them. standard_deduction = tax_unit("standard_deduction", period) - itemized_deductions = tax_unit("itemized_taxable_income_deductions", period) + tax_unit("total_itemized_taxable_income_deductions", period) + # The itemized deductions the unit would claim if it itemized. From + # 2026 the section 68 limitation nets them of the qualified business + # income deduction, which depends on the itemization choice, so they + # come from the branch that itemizes (the one that also gives + # tax_liability_if_itemizing). + itemizing_branch = get_override_branch( + tax_unit.simulation, + "itemizing", + period, + {"tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool)}, + ) + itemized_deductions = itemizing_branch.calculate( + "itemized_taxable_income_deductions", period + ) if parameters(period).gov.simulation.branch_to_determine_itemization: # determine federal itemization behavior by comparing tax liability tax_liability_if_itemizing = tax_unit("tax_liability_if_itemizing", period) diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py index b406cb6a7ed..1311e8e97cf 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py @@ -56,9 +56,10 @@ def formula(tax_unit, period, parameters): threshold = parameters(period).gov.irs.income.bracket.thresholds["6"][ filing_status ] - agi = tax_unit("adjusted_gross_income", period) - exemptions = tax_unit("exemptions", period) - excess = max_(0, agi - exemptions - threshold) + taxable_income = tax_unit( + "taxable_income_before_itemized_deductions", period + ) + excess = max_(0, taxable_income - threshold) limitation_adjustment = p.obbb.rate * ( min_(total, excess) - min_(corrected_total, excess) ) From 0624217ff6b25ba8de1a640802fb1e7bf6aa7af6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 21:31:26 -0400 Subject: [PATCH 2/4] Test the 2026 section 68 taxable-income leg from statute arithmetic - YAML cases for single, joint, separate and head-of-household filers, with 2026 37 percent bracket starts from Rev. Proc. 2025-32 sec. 4.01, and a qualified REIT dividend case where the QBI deduction is capped by taxable income computed without regard to section 68 (199A(e)(1)). - taxable_income_before_itemized_deductions unit cases (63(b)/(d), the 2029 end of the OBBB deductions, wagering losses as an itemized deduction). - An itemization-choice case the corrected limitation flips. - Property and differential tests: leg (2) and the reduction restated from the statute; bounds; each 63(b) deduction lowers leg (2) one for one; monotonicity in AGI and itemized components; a calculated QBI deduction with the itemization choice set and calculated. Co-Authored-By: Claude Opus 5.5 --- ...temized_deduction_limitation_properties.py | 244 +++++++++++++++++- ...d_taxable_income_deductions_reduction.yaml | 160 ++++++++++++ ...ble_income_before_itemized_deductions.yaml | 68 +++++ .../qbid/taxable_income_less_qbid.yaml | 44 ++++ .../deductions/tax_unit_itemizes.yaml | 37 +++ 5 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.yaml diff --git a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py index 36c428c7094..a3d25361d12 100644 --- a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py +++ b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py @@ -29,6 +29,31 @@ 7. With the OBBB structure switched off in 2026 (the prior-law counterfactual), the pre-2026 worksheet logic applies with the 2026 applicable amounts. +From 2026 section 68(a) reduces itemized deductions by 2/37 of the lesser of +(1) the itemized deductions and (2) "so much of the taxable income ... +(determined without regard to this section and increased by such amount of +itemized deductions) as exceeds the dollar amount at which the 37 percent rate +bracket under section 1 begins". Leg (2) is AGI less personal exemptions and +the deductions section 63(b) allows besides itemized deductions (section +63(d)(2)): the section 199A, 224 and 225 deductions, the section 151(d)(5)(C) +senior deduction and qualified passenger vehicle loan interest. Section +199A(e)(1), as amended by P.L. 119-21, sec. 70111(b), computes the QBI +deduction's taxable income without regard to section 68. The tests check: + +8. The reduction equals 2/37 of the lesser of the itemized deductions and + AGI less those deductions over the Rev. Proc. 2025-32 bracket start, + restated independently, and lies between 0 and 2/37 of the itemized + deductions. +9. Each of those deductions lowers leg (2) one for one, and so never raises + the reduction or lowers it by more than 2/37 of itself; wagering losses, + an itemized deduction, leave leg (2) unchanged. More AGI never lowers the + reduction, and more of any itemized deduction never lowers the allowed + deductions. +10. With the QBI deduction calculated from qualified REIT dividends, it is the + lesser of 20 percent of the dividends and 20 percent of AGI less the + itemized deductions before section 68, and the reduction nets it out of + leg (2), whether the itemization choice is set or calculated. + Gambling losses are deducted outside total_itemized_taxable_income_deductions (wagering_losses_deduction), so they never enter the limited base; they are not generated here. @@ -109,24 +134,29 @@ def _draw(seed): return units -def _simulate(year, units, reform=None): +def _simulate( + year, units, reform=None, tax_unit_inputs=(), person_inputs=(), outputs=OUTPUTS +): period = str(year) people, tax_units, households = {}, {}, {} for i in range(N): person = f"p{i}" people[person] = {"age": {period: 50}} - for name in PERSON_COMPONENTS: + for name in PERSON_COMPONENTS + tuple(person_inputs): people[person][name] = {period: float(units[name][i])} tax_units[f"t{i}"] = { "members": [person], "filing_status": {period: str(units["filing_status"][i])}, "adjusted_gross_income": {period: float(units["adjusted_gross_income"][i])}, - **{name: {period: float(units[name][i])} for name in COMPONENTS}, + **{ + name: {period: units[name][i].item()} + for name in COMPONENTS + tuple(tax_unit_inputs) + }, } households[f"h{i}"] = {"members": [person]} situation = {"people": people, "tax_units": tax_units, "households": households} simulation = Simulation(situation=situation, reform=reform) - return {name: simulation.calculate(name, period) for name in OUTPUTS} + return {name: simulation.calculate(name, period) for name in outputs} def _split(units): @@ -285,3 +315,209 @@ def test_2026_prior_law_counterfactual_uses_the_worksheet(): amounts = _parameter_amounts(2026, units["filing_status"]) _check_pre_2026_structure(units, results, amounts) assert np.any(results["itemized_taxable_income_deductions_reduction"] > 0) + + +# Rev. Proc. 2025-32, sec. 4.01, Tables 1-4: the 2026 37 percent rate bracket +# begins over these amounts of taxable income. +TOP_BRACKET_STARTS_2026 = { + "JOINT": 768_700, + "SURVIVING_SPOUSE": 768_700, + "HEAD_OF_HOUSEHOLD": 640_600, + "SINGLE": 640_600, + "SEPARATE": 384_350, +} +# Deductions section 63(b) allows whether or not the filer itemizes, which +# section 63(d)(2) keeps out of itemized deductions. +NON_ITEMIZED = ( + "qualified_business_income_deduction", + "tip_income_deduction", + "overtime_income_deduction", + "additional_senior_deduction", + "auto_loan_interest_deduction", +) +OUTPUTS_2026 = OUTPUTS + ( + "taxable_income_before_itemized_deductions", + "taxable_income_less_qbid", + "qualified_business_income_deduction", + "exemptions", +) + + +def _top_bracket_starts(statuses): + return np.array([TOP_BRACKET_STARTS_2026[status] for status in statuses]) + + +def _draw_2026(seed): + """Seeded 2026 itemizers with deductions other than itemized deductions.""" + units = _draw(seed) + rng = np.random.default_rng(seed + 1) + for name in NON_ITEMIZED: + cap = 150_000 if name == "qualified_business_income_deduction" else 25_000 + amount = np.round(rng.uniform(0, cap, N), 2) + units[name] = np.where(rng.random(N) < 0.5, 0, amount) + units["wagering_losses_deduction"] = np.zeros(N) + units["tax_unit_itemizes"] = np.ones(N, dtype=bool) + return units + + +def _simulate_2026(units, **kwargs): + inputs = NON_ITEMIZED + ("wagering_losses_deduction", "tax_unit_itemizes") + return _simulate( + 2026, units, tax_unit_inputs=inputs, outputs=OUTPUTS_2026, **kwargs + ) + + +def _section_68_reduction(itemized, income_before_itemized, statuses): + """2/37 of the lesser of section 68(a)(1) and (a)(2), restated.""" + excess = np.maximum(0, income_before_itemized - _top_bracket_starts(statuses)) + return np.minimum(itemized, excess) * 2 / 37 + + +def _check_2026_structure(units, results, non_itemized_total): + statuses = units["filing_status"] + itemized = results["total_itemized_taxable_income_deductions"] + reduction = results["itemized_taxable_income_deductions_reduction"] + allowed = results["itemized_taxable_income_deductions"] + agi = units["adjusted_gross_income"] + scale = agi + itemized + non_itemized_total + # Section 151(d)(5)(A): the exemption amount is zero after 2017. + assert np.all(results["exemptions"] == 0) + + # 8. Leg (2) and the reduction, restated from the statute. + _close( + results["taxable_income_before_itemized_deductions"], + agi - non_itemized_total, + scale, + "leg (2)", + ) + expected = _section_68_reduction(itemized, agi - non_itemized_total, statuses) + _close(reduction, expected, scale, "reduction") + _close(allowed, itemized - expected, scale, "allowed") + assert np.all(reduction >= 0) + assert np.all(reduction <= itemized * 2 / 37 + _slack(scale)) + return expected + + +def test_2026_bracket_starts_match_the_parameters(): + p = _system().parameters("2026-01-01").gov.irs.income.bracket.thresholds + statuses = np.array(list(TOP_BRACKET_STARTS_2026)) + np.testing.assert_array_equal( + np.asarray(p["6"][statuses]), _top_bracket_starts(statuses) + ) + + +def test_2026_taxable_income_leg_nets_non_itemized_deductions(): + units = _draw_2026(seed=2126) + results = _simulate_2026(units) + non_itemized_total = sum(units[name] for name in NON_ITEMIZED) + expected = _check_2026_structure(units, results, non_itemized_total) + # The draws cover both legs binding and no reduction. + itemized = results["total_itemized_taxable_income_deductions"] + excess = ( + units["adjusted_gross_income"] + - non_itemized_total + - _top_bracket_starts(units["filing_status"]) + ) + assert np.any((expected > 0) & (excess < itemized)) + assert np.any((expected > 0) & (excess > itemized)) + assert np.any(expected == 0) + # Without the 63(b) deductions some of these units would be reduced. + assert np.any( + (expected == 0) + & ( + _section_68_reduction( + itemized, units["adjusted_gross_income"], units["filing_status"] + ) + > 0 + ) + ) + + +def test_2026_reduction_responds_to_each_deduction_and_income(): + # 9. One for one in leg (2); never a larger reduction. + units = _draw_2026(seed=2127) + base = _simulate_2026(units) + base_leg = base["taxable_income_before_itemized_deductions"] + base_reduction = base["itemized_taxable_income_deductions_reduction"] + base_allowed = base["itemized_taxable_income_deductions"] + non_itemized_total = sum(units[name] for name in NON_ITEMIZED) + scale = ( + units["adjusted_gross_income"] + + base["total_itemized_taxable_income_deductions"] + + non_itemized_total + + 10_000 + ) + step = 1_000 + for name in NON_ITEMIZED + ("wagering_losses_deduction",): + raised = dict(units, **{name: units[name] + step}) + results = _simulate_2026(raised) + leg = results["taxable_income_before_itemized_deductions"] + reduction = results["itemized_taxable_income_deductions_reduction"] + if name == "wagering_losses_deduction": + # Wagering losses are itemized deductions: section 68(a)(2) adds + # them back, so leg (2) does not move. + _close(leg, base_leg, scale, name) + continue + _close(leg, base_leg - step, scale, name) + assert np.all(reduction <= base_reduction + _slack(scale)), name + assert np.all(reduction >= base_reduction - step * 2 / 37 - _slack(scale)), name + assert np.any(reduction < base_reduction - 1), name + + for name in ALL_COMPONENTS: + raised = dict(units, **{name: units[name] + step}) + allowed = _simulate_2026(raised)["itemized_taxable_income_deductions"] + assert np.all(allowed >= base_allowed - _slack(scale)), name + + richer = dict(units, adjusted_gross_income=units["adjusted_gross_income"] + 10_000) + richer_reduction = _simulate_2026(richer)[ + "itemized_taxable_income_deductions_reduction" + ] + assert np.all(richer_reduction >= base_reduction - _slack(scale)) + + +@pytest.mark.parametrize("choice", ["set", "calculated"]) +def test_2026_calculated_qbi_deduction_ignores_section_68(choice): + # 10. The QBI deduction from qualified REIT dividends (section + # 199A(b)(1)(B)), capped at 20 percent of taxable income computed without + # regard to section 68 (199A(a)(2), (e)(1)), and the reduction net of it. + units = _draw(seed=2128) + rng = np.random.default_rng(2129) + units["qualified_reit_and_ptp_income"] = np.round(rng.uniform(0, 1_000_000, N), 2) + tax_unit_inputs = () + if choice == "set": + units["tax_unit_itemizes"] = np.ones(N, dtype=bool) + tax_unit_inputs = ("tax_unit_itemizes",) + results = _simulate( + 2026, + units, + tax_unit_inputs=tax_unit_inputs, + person_inputs=("qualified_reit_and_ptp_income",), + outputs=OUTPUTS_2026 + ("tax_unit_itemizes",), + ) + itemizes = results["tax_unit_itemizes"] + if choice == "calculated": + # High-income itemizers with these deductions mostly itemize. + assert itemizes.mean() > 0.5 + agi = units["adjusted_gross_income"] + itemized = results["total_itemized_taxable_income_deductions"] + qbid = results["qualified_business_income_deduction"] + scale = agi + itemized + units["qualified_reit_and_ptp_income"] + expected_qbid = np.minimum( + 0.2 * units["qualified_reit_and_ptp_income"], + 0.2 * np.maximum(0, agi - itemized), + ) + _close( + results["taxable_income_less_qbid"][itemizes], + np.maximum(0, agi - itemized)[itemizes], + scale[itemizes], + "199A taxable income", + ) + _close(qbid[itemizes], expected_qbid[itemizes], scale[itemizes], "QBID") + expected = _section_68_reduction(itemized, agi - qbid, units["filing_status"]) + _close( + results["itemized_taxable_income_deductions_reduction"][itemizes], + expected[itemizes], + scale[itemizes], + "reduction", + ) + assert np.any(expected[itemizes] > 0) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml index 965f6ed655c..4c70d5d80f5 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_taxable_income_deductions_reduction.yaml @@ -164,3 +164,163 @@ # Excess over the 37 percent bracket: 900_000 - 640_600 = 259_400 # Lesser: 200_000; reduction: 200_000 * 2 / 37 = 10_810.81 itemized_taxable_income_deductions_reduction: 10_810.81 + +# Section 68(a)(2) as rewritten by P.L. 119-21, sec. 70111(a): "so much of the +# taxable income of the taxpayer for the taxable year (determined without +# regard to this section and increased by such amount of itemized +# deductions) as exceeds the dollar amount at which the 37 percent rate +# bracket under section 1 begins". That is adjusted gross income less the +# deductions section 63(b) allows besides itemized deductions, such as the +# qualified business income deduction. The 37 percent bracket begins at +# $640,600 (single, head of household), $768,700 (joint, surviving spouse) and +# $384,350 (separate) in 2026 (Rev. Proc. 2025-32, sec. 4.01, Tables 1-4). +- name: 2026 single filer - the QBI deduction lowers the taxable income leg + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 700_000 + filing_status: SINGLE + qualified_business_income_deduction: 20_000 + charitable_deduction: 50_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Leg (1): 50_000 + # Leg (2): 700_000 - 20_000 = 680_000 (taxable income 630_000 plus the + # 50_000 of itemized deductions); 680_000 - 640_600 = 39_400 + # Reduction: 2 / 37 * 39_400 = 2_129.73 + taxable_income_before_itemized_deductions: 680_000 + itemized_taxable_income_deductions_reduction: 2_129.73 + itemized_taxable_income_deductions: 47_870.27 + +- name: 2026 single filer - the QBI deduction keeps the taxable income leg under the 37 percent bracket + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 700_000 + filing_status: SINGLE + qualified_business_income_deduction: 60_000 + charitable_deduction: 50_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Leg (2): 700_000 - 60_000 = 640_000, below 640_600, so no reduction. + itemized_taxable_income_deductions_reduction: 0 + itemized_taxable_income_deductions: 50_000 + +- name: 2026 joint filers - the QBI and tip deductions lower the taxable income leg + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 1_000_000 + filing_status: JOINT + qualified_business_income_deduction: 150_000 + tip_income_deduction: 10_000 + charitable_deduction: 100_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + households: + household: + members: [head, spouse] + output: + # Leg (1): 100_000 + # Leg (2): 1_000_000 - 150_000 - 10_000 = 840_000; 840_000 - 768_700 = 71_300 + # Reduction: 2 / 37 * 71_300 = 3_854.05 + itemized_taxable_income_deductions_reduction: 3_854.05 + itemized_taxable_income_deductions: 96_145.95 + +- name: 2026 separate filer - the QBI deduction lowers the taxable income leg + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 450_000 + filing_status: SEPARATE + qualified_business_income_deduction: 40_000 + charitable_deduction: 30_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Leg (2): 450_000 - 40_000 = 410_000; 410_000 - 384_350 = 25_650 + # Reduction: 2 / 37 * 25_650 = 1_386.49 + itemized_taxable_income_deductions_reduction: 1_386.49 + +- name: 2026 head of household - the itemized deductions leg binds whatever the QBI deduction + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 800_000 + filing_status: HEAD_OF_HOUSEHOLD + qualified_business_income_deduction: 100_000 + charitable_deduction: 20_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Leg (2): 800_000 - 100_000 = 700_000; 700_000 - 640_600 = 59_400 + # Lesser of 20_000 and 59_400: 20_000; 2 / 37 * 20_000 = 1_081.08 + itemized_taxable_income_deductions_reduction: 1_081.08 + +# Section 199A(e)(1), as amended by P.L. 119-21, sec. 70111(b)(1): "taxable +# income shall be computed without regard to section 68 and without regard to +# any deduction allowable under this section." The QBI deduction caps at 20 +# percent of that taxable income (section 199A(a)(2)), and section 68 then +# nets the QBI deduction out of its own taxable income leg. +- name: 2026 single filer with qualified REIT dividends - QBI deduction capped by taxable income without section 68 + period: 2026 + # Float32 spacing near $572,000 is 6.25 cents. + absolute_error_margin: 0.1 + input: + people: + head: + age: 50 + non_qualified_dividend_income: 1_000_000 + qualified_reit_and_ptp_income: 1_000_000 + tax_units: + tax_unit: + members: [head] + tax_unit_itemizes: true + charitable_deduction: 300_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + households: + household: + members: [head] + state_code: TX + output: + adjusted_gross_income: 1_000_000 + # Taxable income without section 68 or 199A: 1_000_000 - 300_000 = 700_000 + taxable_income_less_qbid: 700_000 + # Section 199A(b)(1)(B): 20 percent of 1_000_000 = 200_000; + # section 199A(a)(2): 20 percent of 700_000 = 140_000; lesser: 140_000 + qualified_business_income_deduction: 140_000 + # Leg (2): 1_000_000 - 140_000 = 860_000; 860_000 - 640_600 = 219_400 + # Lesser of 300_000 and 219_400: 219_400; 2 / 37 * 219_400 = 11_859.46 + itemized_taxable_income_deductions_reduction: 11_859.46 + itemized_taxable_income_deductions: 288_140.54 + # 1_000_000 - 288_140.54 - 140_000 = 571_859.46 + taxable_income: 571_859.46 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.yaml new file mode 100644 index 00000000000..011a2e9d3bb --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/taxable_income_before_itemized_deductions.yaml @@ -0,0 +1,68 @@ +# 26 U.S.C. 63(b) lists the deductions taken whether or not the filer +# itemizes: personal exemptions under section 151 (which include the section +# 151(d)(5)(C) senior deduction), the section 199A, 224 and 225 deductions, +# and qualified passenger vehicle loan interest under section 163(h)(4). +# Section 63(d)(2) keeps them out of "itemized deductions"; wagering losses +# (section 165(d)) are not listed, so they are itemized deductions. Section +# 68(a)(2) adds the itemized deductions back to taxable income, leaving +# adjusted gross income less personal exemptions and the 63(b) deductions. +- name: 2026 single filer - every non-itemized deduction counts, wagering losses do not + period: 2026 + input: + adjusted_gross_income: 500_000 + filing_status: SINGLE + qualified_business_income_deduction: 1_000 + tip_income_deduction: 2_000 + overtime_income_deduction: 3_000 + additional_senior_deduction: 4_000 + auto_loan_interest_deduction: 5_000 + wagering_losses_deduction: 6_000 + charitable_deduction: 7_000 + output: + # 500_000 - 1_000 - 2_000 - 3_000 - 4_000 - 5_000 = 485_000 + taxable_income_before_itemized_deductions: 485_000 + +- name: 2026 joint filers with only a qualified business income deduction + period: 2026 + input: + people: + head: + age: 50 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + adjusted_gross_income: 1_000_000 + filing_status: JOINT + qualified_business_income_deduction: 150_000 + households: + household: + members: [head, spouse] + output: + taxable_income_before_itemized_deductions: 850_000 + +- name: Not floored at zero + period: 2026 + input: + adjusted_gross_income: 10_000 + filing_status: SINGLE + qualified_business_income_deduction: 15_000 + output: + taxable_income_before_itemized_deductions: -5_000 + +- name: 2030, after the tip, overtime, senior and vehicle loan deductions end + period: 2030 + input: + adjusted_gross_income: 500_000 + filing_status: SINGLE + qualified_business_income_deduction: 1_000 + tip_income_deduction: 2_000 + overtime_income_deduction: 3_000 + additional_senior_deduction: 4_000 + auto_loan_interest_deduction: 5_000 + output: + # Sections 224(h), 225(g), 151(d)(5)(C)(i) and 163(h)(4)(A), as added by + # P.L. 119-21, allow these deductions only for taxable years beginning + # before 2029, so only the QBI deduction is subtracted. + taxable_income_before_itemized_deductions: 499_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/taxable_income_less_qbid.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/taxable_income_less_qbid.yaml index 41bbe35faf5..a45d3a978a6 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/taxable_income_less_qbid.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/taxable_income_less_qbid.yaml @@ -20,3 +20,47 @@ salt_deduction: 0 output: taxable_income_less_qbid: 36_000 + +# Section 199A(e)(1), as amended by P.L. 119-21, sec. 70111(b)(1), computes +# taxable income "without regard to section 68" from 2026, so an itemizer +# subtracts the itemized deductions before the section 68 reduction. +- name: 2026 itemizer - itemized deductions before the section 68 reduction + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 900_000 + filing_status: SINGLE + tax_unit_itemizes: true + charitable_deduction: 200_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Section 68 reduces the itemized deductions by 2 / 37 * 200_000 = + # 10_810.81, which taxable income for section 199A ignores. + itemized_taxable_income_deductions_reduction: 10_810.81 + taxable_income_less_qbid: 700_000 + +- name: 2026 without the P.L. 119-21 section 68 - itemized deductions after the reduction + period: 2026 + absolute_error_margin: 0.01 + input: + gov.irs.deductions.itemized.limitation.obbb.applies: false + adjusted_gross_income: 900_000 + filing_status: SINGLE + tax_unit_itemizes: true + charitable_deduction: 10_000 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + output: + # Before P.L. 119-21, section 199A(e)(1) did not set section 68 aside. + # The prior-law reduction is the lesser of 80 percent of the 10_000 of + # charitable deductions (8_000) and 3 percent of AGI over the applicable + # amount (over 8_000 at this income), so 8_000. + itemized_taxable_income_deductions_reduction: 8_000 + taxable_income_less_qbid: 898_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml index 50355e8ce9d..a4ee533a4ec 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml @@ -55,6 +55,43 @@ output: tax_unit_itemizes: False +- name: 2026 - the itemization choice uses the section 68 limitation net of the QBI deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 400_000 + non_qualified_dividend_income: 300_000 + qualified_reit_and_ptp_income: 300_000 + tax_units: + tax_unit: + members: [head] + charitable_deduction: 16_500 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + households: + household: + members: [head] + state_code: TX + output: + # QBI deduction: 20 percent of the 300_000 of qualified REIT dividends, + # 60_000, under 20 percent of taxable income either way (199A(a)). + qualified_business_income_deduction: 60_000 + # Section 68(a)(2): 700_000 - 60_000 = 640_000 does not exceed the + # 640_600 single 37 percent bracket start (Rev. Proc. 2025-32, sec. + # 4.01, Table 3), so the 16_500 of itemized deductions is not reduced and + # exceeds the 16_100 standard deduction (sec. 4.14). + itemized_taxable_income_deductions: 16_500 + standard_deduction: 16_100 + tax_unit_itemizes: true + # 700_000 - 16_500 - 60_000 + taxable_income: 623_500 + - name: Integration test which fails without tolerance in the itemization choice period: 2024 absolute_error_margin: 0.1 From adaa40787bd78efb652702649d3011c598cb63f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:55:00 -0400 Subject: [PATCH 3/4] Branch for itemized deductions only under the P.L. 119-21 section 68 Review follow-ups (GPT-6.1 Sol, approve with nits): - tax_unit_itemizes reads itemized deductions from the itemizing branch only when the rewritten section 68 is in force, the only case where they depend on the itemization choice; other years and the prior-law counterfactual keep the main-simulation value, so non-branching mode adds no branch there. - A 2026 case with branch_to_determine_itemization false. - The property tests' docstring states that gambling losses outside the limited base is a model limitation (#10070), not the law; the 2026 monotonicity check raises one itemized component instead of seven. Co-Authored-By: Claude Opus 5.5 --- ...temized_deduction_limitation_properties.py | 18 ++++++---- .../deductions/tax_unit_itemizes.yaml | 30 ++++++++++++++++ .../deductions/tax_unit_itemizes.py | 36 ++++++++++--------- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py index a3d25361d12..066e7e71e98 100644 --- a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py +++ b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py @@ -54,9 +54,11 @@ itemized deductions before section 68, and the reduction nets it out of leg (2), whether the itemization choice is set or calculated. -Gambling losses are deducted outside total_itemized_taxable_income_deductions -(wagering_losses_deduction), so they never enter the limited base; they are -not generated here. +PolicyEngine deducts gambling losses outside +total_itemized_taxable_income_deductions (wagering_losses_deduction), so they +never enter the limited base here; that is a model limitation, not the law +from 2026 (#10070). They are not generated, except that test 9 checks they +leave section 68(a)(2)'s taxable income unchanged. """ from functools import cache @@ -463,10 +465,12 @@ def test_2026_reduction_responds_to_each_deduction_and_income(): assert np.all(reduction >= base_reduction - step * 2 / 37 - _slack(scale)), name assert np.any(reduction < base_reduction - 1), name - for name in ALL_COMPONENTS: - raised = dict(units, **{name: units[name] + step}) - allowed = _simulate_2026(raised)["itemized_taxable_income_deductions"] - assert np.all(allowed >= base_allowed - _slack(scale)), name + # The 2026 reduction depends on the itemized deductions only through + # their total (test 6), so one component stands for all of them. + raised = dict(units, charitable_deduction=units["charitable_deduction"] + step) + allowed = _simulate_2026(raised)["itemized_taxable_income_deductions"] + assert np.all(allowed >= base_allowed - _slack(scale)) + assert np.all(allowed <= base_allowed + step + _slack(scale)) richer = dict(units, adjusted_gross_income=units["adjusted_gross_income"] + 10_000) richer_reduction = _simulate_2026(richer)[ diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml index a4ee533a4ec..e6f84cbf719 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml @@ -92,6 +92,36 @@ # 700_000 - 16_500 - 60_000 taxable_income: 623_500 +- name: 2026 without branching - the deduction comparison uses the section 68 limitation net of the QBI deduction + period: 2026 + absolute_error_margin: 0.01 + input: + gov.simulation.branch_to_determine_itemization: false + people: + head: + age: 50 + employment_income: 400_000 + non_qualified_dividend_income: 300_000 + qualified_reit_and_ptp_income: 300_000 + tax_units: + tax_unit: + members: [head] + charitable_deduction: 16_500 + interest_deduction: 0 + salt_deduction: 0 + medical_expense_deduction: 0 + casualty_loss_deduction: 0 + misc_deduction: 0 + households: + household: + members: [head] + state_code: TX + output: + # As in the case above: leg (2) is 700_000 - 60_000 = 640_000, so the + # 16_500 of itemized deductions is not reduced and exceeds 16_100. + itemized_taxable_income_deductions: 16_500 + tax_unit_itemizes: true + - name: Integration test which fails without tolerance in the itemization choice period: 2024 absolute_error_margin: 0.1 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py index d5eefb89b3a..ab7736c1835 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.py @@ -10,23 +10,27 @@ class tax_unit_itemizes(Variable): definition_period = YEAR def formula(tax_unit, period, parameters): - # Calculated before branching, so the itemizing branch inherits them. standard_deduction = tax_unit("standard_deduction", period) - tax_unit("total_itemized_taxable_income_deductions", period) - # The itemized deductions the unit would claim if it itemized. From - # 2026 the section 68 limitation nets them of the qualified business - # income deduction, which depends on the itemization choice, so they - # come from the branch that itemizes (the one that also gives - # tax_liability_if_itemizing). - itemizing_branch = get_override_branch( - tax_unit.simulation, - "itemizing", - period, - {"tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool)}, - ) - itemized_deductions = itemizing_branch.calculate( - "itemized_taxable_income_deductions", period - ) + limitation = parameters(period).gov.irs.deductions.itemized.limitation + if limitation.applies and limitation.obbb.applies: + # The itemized deductions the unit would claim if it itemized. + # The P.L. 119-21 section 68 limitation nets them of the + # qualified business income deduction, which depends on the + # itemization choice, so they come from the branch that itemizes + # (the one that also gives tax_liability_if_itemizing). + # Calculated before branching, so the branch inherits it. + tax_unit("total_itemized_taxable_income_deductions", period) + itemizing_branch = get_override_branch( + tax_unit.simulation, + "itemizing", + period, + {"tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool)}, + ) + itemized_deductions = itemizing_branch.calculate( + "itemized_taxable_income_deductions", period + ) + else: + itemized_deductions = tax_unit("itemized_taxable_income_deductions", period) if parameters(period).gov.simulation.branch_to_determine_itemization: # determine federal itemization behavior by comparing tax liability tax_liability_if_itemizing = tax_unit("tax_liability_if_itemizing", period) From 50c302cee8391f485e8ddc11a76e721708eae790 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:07:10 -0400 Subject: [PATCH 4/4] Tighten the 2026 monotonicity bound and pin the QBI deduction in the non-branching case Optional nits from the Sol delta review (approve): allowed itemized deductions rise by at least 35/37 of an increase; the comment cites tests 6 and 8; the non-branching itemization case asserts the 60,000 QBI deduction. Co-Authored-By: Claude Opus 5.5 --- .../core/test_itemized_deduction_limitation_properties.py | 6 ++++-- .../income/taxable_income/deductions/tax_unit_itemizes.yaml | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py index 066e7e71e98..39b433c0f4b 100644 --- a/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py +++ b/policyengine_us/tests/core/test_itemized_deduction_limitation_properties.py @@ -466,10 +466,12 @@ def test_2026_reduction_responds_to_each_deduction_and_income(): assert np.any(reduction < base_reduction - 1), name # The 2026 reduction depends on the itemized deductions only through - # their total (test 6), so one component stands for all of them. + # their total (tests 6 and 8), so one component stands for all of them. + # It rises by at most 2/37 of the increase, so allowed deductions rise by + # at least 35/37 of it. raised = dict(units, charitable_deduction=units["charitable_deduction"] + step) allowed = _simulate_2026(raised)["itemized_taxable_income_deductions"] - assert np.all(allowed >= base_allowed - _slack(scale)) + assert np.all(allowed >= base_allowed + step * 35 / 37 - _slack(scale)) assert np.all(allowed <= base_allowed + step + _slack(scale)) richer = dict(units, adjusted_gross_income=units["adjusted_gross_income"] + 10_000) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml index e6f84cbf719..90e8017e8c4 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_itemizes.yaml @@ -119,6 +119,7 @@ output: # As in the case above: leg (2) is 700_000 - 60_000 = 640_000, so the # 16_500 of itemized deductions is not reduced and exceeds 16_100. + qualified_business_income_deduction: 60_000 itemized_taxable_income_deductions: 16_500 tax_unit_itemizes: true