Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-68-taxable-income-leg.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,36 @@
7. With the OBBB structure switched off in 2026 (the prior-law counterfactual),
the pre-2026 worksheet logic applies with the 2026 applicable amounts.

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.
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.

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
Expand Down Expand Up @@ -109,24 +136,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):
Expand Down Expand Up @@ -285,3 +317,213 @@ 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

# The 2026 reduction depends on the itemized deductions only through
# 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 + 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)
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)
Loading
Loading