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/hub-qbid-net-capital-gain-1010.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Strengthen qualified business income deduction income-limit coverage for net short-term losses, capital gain distributions, and comparison with the former adjusted-capital-gain cap. Cite Treasury's explicit treatment of elected investment income under Section 199A.
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,27 @@
neither the gain nor the QBI deduction.
4. Cap and floor: the deduction is never negative and never above 20% of
taxable income less the gain, or the 199A(i) minimum when larger.
5. Gain and deduction ordering: for the same component-derived inputs,
the 199A gain is nonnegative and at least adjusted net capital gain, so
its deduction never exceeds the deduction using the former adjusted-gain
cap, including when the 199A(i) minimum applies.
"""

import numpy as np
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st
from policyengine_core.periods import period

from policyengine_us import Simulation
from policyengine_us.variables.gov.irs.income.taxable_income.deductions.qualified_business_income_deduction.qualified_business_income_deduction import (
qualified_business_income_deduction,
)

TOLERANCE = 0.01 # dollars
GAIN_INPUTS = [
"long_term_capital_gains",
"short_term_capital_gains",
"schedule_d_capital_gain_distributions",
"non_sch_d_capital_gains",
"qualified_dividend_income",
"long_term_capital_gains_on_collectibles",
Expand All @@ -59,10 +68,14 @@ def _maybe(strategy):

@st.composite
def person_amounts(draw):
long_term = draw(_maybe(signed))
distributions = draw(_maybe(st.integers(1, 5_000).map(float)))
# Schedule D line 13 is already inside line 15. The separately stored
# distribution is a memo component, never an additional gain.
long_term = draw(_maybe(signed)) + distributions
return {
"long_term_capital_gains": long_term,
"short_term_capital_gains": draw(_maybe(signed)),
"schedule_d_capital_gain_distributions": distributions,
"non_sch_d_capital_gains": draw(_maybe(st.integers(1, 5_000).map(float))),
"qualified_dividend_income": draw(_maybe(positive)),
# Collectibles gain is part of the long-term gain.
Expand Down Expand Up @@ -127,7 +140,22 @@ def by_year(values):
}


def _run(units, year, **kwargs):
class _AdjustedGainCap:
"""Run the production deduction with only its former gain input restored."""

def __init__(self, tax_unit):
self.tax_unit = tax_unit

def __call__(self, variable, formula_period):
if variable == "section_199a_net_capital_gain":
variable = "adjusted_net_capital_gain"
return self.tax_unit(variable, formula_period)

def __getattr__(self, name):
return getattr(self.tax_unit, name)


def _run(units, year, *, compare_legacy=False, **kwargs):
sim = Simulation(situation=_situation(units, year, **kwargs))
out = {
name: np.asarray(sim.calculate(name, year), dtype=float)
Expand All @@ -141,6 +169,21 @@ def _run(units, year, **kwargs):
out["filer"] = ~np.asarray(sim.calculate("is_tax_unit_dependent", year), bool)
out["unit"] = sim.populations["tax_unit"].members_entity_id
out["p"] = sim.tax_benefit_system.parameters(year).gov.irs.deductions.qbi
if compare_legacy:
out["adjusted_net_capital_gain"] = np.asarray(
sim.calculate("adjusted_net_capital_gain", year), dtype=float
)
# Reuse this simulation's population and parameters. This evaluates
# the actual deduction formula, including its floor and filer rules,
# without another model build or a duplicate implementation of QBID.
out["legacy_qualified_business_income_deduction"] = np.asarray(
qualified_business_income_deduction.formula(
_AdjustedGainCap(sim.populations["tax_unit"]),
period(year),
sim.tax_benefit_system.parameters,
),
dtype=float,
)
return out


Expand All @@ -150,7 +193,7 @@ def _filer_sum(run, name):


def _check(units, year):
run = _run(units, year)
run = _run(units, year, compare_legacy=True)
gain = run["section_199a_net_capital_gain"]
deduction = run["qualified_business_income_deduction"]

Expand Down Expand Up @@ -193,6 +236,15 @@ def _check(units, year):
assert (deduction >= -TOLERANCE).all()
assert (deduction <= ceiling + TOLERANCE).all()

# 5. Preferential-rate exclusions and elections can only reduce the
# former gain input. Replacing it cannot increase the deduction, even
# when the common statutory minimum exceeds either ordinary cap.
assert (gain >= -TOLERANCE).all()
assert (gain + TOLERANCE >= run["adjusted_net_capital_gain"]).all()
assert (
deduction <= run["legacy_qualified_business_income_deduction"] + TOLERANCE
).all()


# Each example is one vectorized batch of tax units.
SETTINGS = dict(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,37 @@
section_199a_net_capital_gain: 45_000
# 20% x (100,000 - 45,000) = 11,000.
qualified_business_income_deduction: 11_000

- name: Income limitation nets short-term loss before retaining section 1250 gain
period: 2025
input:
qbid_amount: 20_000
taxable_income_less_qbid: 100_000
long_term_capital_gains: 30_000
short_term_capital_gains: -10_000
qualified_dividend_income: 5_000
# Schedule D line 19, already reduced for losses.
unrecaptured_section_1250_gain: 15_000
output:
# 2025 Form 8995 instructions, page 4, line 12:
# Schedule D line 15 = $30,000; line 16 = $30,000 - $10,000 = $20,000.
# Form 8995 line 12 = min($30,000, $20,000) + $5,000 = $25,000.
section_199a_net_capital_gain: 25_000
adjusted_net_capital_gain: 10_000
# Lines 13-15: min($20,000, 20% x ($100,000 - $25,000)) = $15,000.
# The former adjusted-gain cap would allow $18,000.
qualified_business_income_deduction: 15_000

- name: Income limitation binds with capital gain distributions without Schedule D
period: 2025
input:
qbid_amount: 8_000
taxable_income_less_qbid: 40_000
non_sch_d_capital_gains: 8_000
qualified_dividend_income: 2_000
output:
# 2025 Form 8995 instructions, page 4, line 12: without Schedule D,
# use Form 1040 line 7a ($8,000) plus line 3a ($2,000) = $10,000.
section_199a_net_capital_gain: 10_000
# Lines 13-15: min($8,000, 20% x ($40,000 - $10,000)) = $6,000.
qualified_business_income_deduction: 6_000
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,17 @@
output:
# 10,000 + max(0, 5,000 - 2,000).
section_199a_net_capital_gain: 13_000

- name: Schedule D distributions are included in long-term gain exactly once
period: 2025
input:
# The $8,000 Schedule D line 13 distribution is already in line 15.
long_term_capital_gains: 30_000
schedule_d_capital_gain_distributions: 8_000
short_term_capital_gains: -10_000
qualified_dividend_income: 5_000
output:
# 2025 Form 8995 instructions, page 4, line 12:
# line 15 = $30,000; line 16 = $20,000; line 3a = $5,000.
# Adding the memo distribution again would incorrectly produce $33,000.
section_199a_net_capital_gain: 25_000
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class section_199a_net_capital_gain(Variable):
title="2025 Instructions for Form 8995-A, line 34",
href="https://www.irs.gov/pub/irs-prior/i8995a--2025.pdf#page=7",
),
dict(
title="TD 9847, Net Capital Gain, 84 FR 2954",
href="https://www.govinfo.gov/content/pkg/FR-2019-02-08/pdf/2019-01025.pdf#page=3",
),
]

def formula(tax_unit, period, parameters):
Expand All @@ -47,6 +51,8 @@ def formula(tax_unit, period, parameters):
# 1(h)(11)(B))". Neither part is reduced by a Form 4952 line 4g
# election: the form uses Form 1040 line 3a, which the Form 4952
# instructions say not to reduce, and Schedule D lines 15 and 16.
# TD 9847's preamble explicitly confirms that elected gains and
# dividends remain net capital gain for the section 199A deduction.
# Schedule D line 15: net long-term gain, including capital gain
# distributions (long-term under 26 U.S.C. 852(b)(3)(B)). Without
# Schedule D, net capital gain is Form 1040 line 7a.
Expand Down
Loading