From 4823383fce9a25e01435df0b716988d7dbb5b5e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 07:57:22 -0400 Subject: [PATCH 1/2] Keep dependents' business income off the filer's QBI deduction qualified_business_income_deduction summed every member's qbid_amount, and qbid_amount netted losses and the 199A(i) minimum over every member, so a dependent's Schedule C income raised the filer's deduction and a dependent's loss cut it. Under 26 USC 199A(a) the deduction is the taxpayer's, from their own (and a joint spouse's) qualified business income; a dependent takes their own deduction on their own return. - qualified_business_income_deduction sums qbid_amount, and the 199A(i) minimum's eligible QBI, over the head and spouse. - qbid_amount nets the head's and spouse's losses together; a dependent's businesses are netted alone for the dependent's return. - qualified_business_income_deduction_person shares the deduction between the head and spouse only. - ia_qbi_deduction takes each spouse's share of the federal deduction; it gave every member with a positive qbid_amount the whole tax unit's deduction, a dependent included. - mo_business_income_deduction counts the head's and spouse's business income only (Mo. Rev. Stat. 143.022: "such individual's business income"). Co-Authored-By: Claude Opus 5.5 --- .../fix-qbid-exclude-dependents.fixed.md | 1 + .../core/test_qbid_dependents_invariants.py | 318 ++++++++++++++++++ .../deductions/qbid/qbid_dependents.yaml | 231 +++++++++++++ .../ia/tax/income/ia_qbi_deduction.yaml | 31 ++ .../mo_business_income_deduction.yaml | 24 ++ .../qbid_amount.py | 19 +- .../qualified_business_income_deduction.py | 13 +- ...lified_business_income_deduction_person.py | 9 +- .../deductions/ia_qbi_deduction.py | 12 +- .../mo_business_income_deduction.py | 6 +- 10 files changed, 643 insertions(+), 21 deletions(-) create mode 100644 changelog.d/fix-qbid-exclude-dependents.fixed.md create mode 100644 policyengine_us/tests/core/test_qbid_dependents_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml diff --git a/changelog.d/fix-qbid-exclude-dependents.fixed.md b/changelog.d/fix-qbid-exclude-dependents.fixed.md new file mode 100644 index 00000000000..92a92192cc0 --- /dev/null +++ b/changelog.d/fix-qbid-exclude-dependents.fixed.md @@ -0,0 +1 @@ +The qualified business income deduction, and the Iowa and Missouri deductions built on it, count only the head's and spouse's business income; a dependent's is on the dependent's own return. diff --git a/policyengine_us/tests/core/test_qbid_dependents_invariants.py b/policyengine_us/tests/core/test_qbid_dependents_invariants.py new file mode 100644 index 00000000000..f2be2df95ab --- /dev/null +++ b/policyengine_us/tests/core/test_qbid_dependents_invariants.py @@ -0,0 +1,318 @@ +"""Invariants for the qualified business income deduction and dependents. + +26 USC 199A(a) allows the deduction to the taxpayer, from the qualified +business income of the taxpayer's own trades or businesses (and the spouse's, +on a joint return). A tax unit dependent with business income files their own +return, so the dependent's income, losses and deduction never reach the +filer's: not the sum of per-person amounts, not the loss netting of +199A(c)(2) and Treas. Reg. 1.199A-1(d)(2), and not the 199A(i) minimum. + +Hypothesis draws batches of tax units (single, head of household with +dependents, joint with and without dependents, in Texas, Missouri and Iowa), +with business income, losses, W-2 wages and property large enough to reach +the 199A(b)(3) phase-in range; a seeded population adds breadth. Each batch +runs as one vectorized simulation, twice: with the dependents' business +inputs as drawn and with them set to zero. For every tax unit: + +1. The dependents' business inputs never change the filer's deduction, its + per-person shares, the head's and spouse's per-person amounts, the + Missouri business income deduction or the Iowa deduction. +2. Conservation: the members' shares sum to the deduction, a dependent's + share is zero, and the members' Iowa deductions sum to Iowa's fraction of + the federal deduction. +3. Differential: the deduction equals an independent numpy computation of + the lesser of the head's and spouse's per-person amounts and 20% of + taxable income less net capital gain, raised from 2026 to the 199A(i) + minimum on the head's and spouse's eligible QBI; Missouri's equals its + rate times the head's and spouse's positive QBI. +4. Bounds: the deduction is never negative, never above 20% of taxable + income less net capital gain (before the minimum), and never above the + previous all-member sum. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +BUSINESS_INPUTS = [ + "self_employment_income", + "sstb_self_employment_income", + "partnership_s_corp_income", + "rental_income", + "w2_wages_from_qualified_business", + "unadjusted_basis_qualified_property", + "qualified_reit_and_ptp_income", +] +TAX_UNIT_OUTPUTS = [ + "qualified_business_income_deduction", + "taxable_income_less_qbid", + "adjusted_net_capital_gain", + "mo_business_income_deduction", +] +PERSON_OUTPUTS = [ + "qbid_amount", + "qualified_business_income_deduction_person", + "qualified_business_income", + "sstb_qualified_business_income", + "business_is_sstb", + "ia_qbi_deduction", +] + +money = st.integers(0, 400_000).map(float) + + +def _maybe(strategy): + return st.one_of(st.just(0.0), strategy) + + +@st.composite +def person_amounts(draw): + return { + "employment_income": draw(_maybe(money)), + "self_employment_income": draw( + _maybe(st.integers(-40_000, 400_000).map(float)) + ), + "sstb_self_employment_income": draw( + _maybe(st.integers(-20_000, 300_000).map(float)) + ), + "partnership_s_corp_income": draw( + _maybe(st.integers(-30_000, 300_000).map(float)) + ), + "rental_income": draw(_maybe(st.integers(-10_000, 50_000).map(float))), + "w2_wages_from_qualified_business": draw(_maybe(money)), + "unadjusted_basis_qualified_property": draw(_maybe(money)), + "qualified_reit_and_ptp_income": draw( + _maybe(st.integers(1, 20_000).map(float)) + ), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "state": draw(st.sampled_from(["TX", "MO", "IA"])), + "head": draw(person_amounts()), + "spouse": draw(person_amounts()) if kind.startswith("joint") else None, + "dependents": [ + {"age": draw(st.integers(5, 23)), **draw(person_amounts())} + for _ in range(n_dependents) + ], + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + def amounts(): + return { + "employment_income": some(1, 400_000, 0.6), + "self_employment_income": some(-40_000, 400_000, 0.6), + "sstb_self_employment_income": some(-20_000, 300_000, 0.3), + "partnership_s_corp_income": some(-30_000, 300_000, 0.3), + "rental_income": some(-10_000, 50_000, 0.2), + "w2_wages_from_qualified_business": some(1, 400_000, 0.4), + "unadjusted_basis_qualified_property": some(1, 400_000, 0.3), + "qualified_reit_and_ptp_income": some(1, 20_000, 0.2), + } + + units = [] + for _ in range(n): + kind = str(rng.choice(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "state": str(rng.choice(["TX", "MO", "IA"])), + "head": amounts(), + "spouse": amounts() if kind.startswith("joint") else None, + "dependents": [ + {"age": int(rng.integers(5, 24)), **amounts()} + for _ in range(n_dependents) + ], + } + ) + return units + + +# Roles are inputs, so a drawn dependent with a large income stays a +# dependent rather than failing the dependency tests and joining the return. +HEAD = dict( + is_tax_unit_head=True, is_tax_unit_spouse=False, is_tax_unit_dependent=False +) +SPOUSE = dict( + is_tax_unit_head=False, is_tax_unit_spouse=True, is_tax_unit_dependent=False +) +DEPENDENT = dict( + is_tax_unit_head=False, is_tax_unit_spouse=False, is_tax_unit_dependent=True +) + + +def _situation(units, year, *, zero_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = {"age": 50, **HEAD, **u["head"]} + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = {"age": 48, **SPOUSE, **u["spouse"]} + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + amounts = {k: v for k, v in d.items() if k != "age"} + if zero_dependents: + amounts.update({name: 0.0 for name in BUSINESS_INPUTS}) + people[child] = { + "age": d["age"], + "is_full_time_student": d["age"] >= 19, + **DEPENDENT, + **amounts, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: u["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, zero_dependents): + sim = Simulation(situation=_situation(units, year, zero_dependents=zero_dependents)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in TAX_UNIT_OUTPUTS + PERSON_OUTPUTS + } + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["filing_status"] = sim.calculate("filing_status", year) + out["state"] = np.asarray(sim.calculate("state_code", year, map_to="tax_unit")) + out["unit"] = sim.populations["tax_unit"].members_entity_id + p = sim.tax_benefit_system.parameters(year) + out["p"] = p.gov.irs.deductions.qbi + out["mo_rate"] = p.gov.states.mo.tax.income.deductions.business_income.rate + out["ia_fraction"] = ( + p.gov.states.ia.tax.income.deductions.qualified_business_income.fraction + ) + return out + + +def _unit_sum(run, values): + n = len(run["qualified_business_income_deduction"]) + return np.bincount(run["unit"], weights=values, minlength=n) + + +def _applicable_rate(run): + p = run["p"] + statuses = run["filing_status"] + start = np.asarray(p.phase_out.start[statuses], dtype=float) + length = np.asarray(p.phase_out.length[statuses], dtype=float) + excess = np.maximum(0, run["taxable_income_less_qbid"] - start) + reduction = np.divide(excess, length, out=np.ones_like(excess), where=length > 0) + return 1 - np.minimum(1, reduction) + + +def _check(units, year): + run = _run(units, year, zero_dependents=False) + zeroed = _run(units, year, zero_dependents=True) + filer = ~run["is_dependent"] + deduction = run["qualified_business_income_deduction"] + + # 1. Dependents' business inputs never reach the filer's return. + for name in TAX_UNIT_OUTPUTS: + np.testing.assert_allclose( + run[name], zeroed[name], atol=TOLERANCE, err_msg=name + ) + for name in PERSON_OUTPUTS: + np.testing.assert_allclose( + run[name][filer], zeroed[name][filer], atol=TOLERANCE, err_msg=name + ) + + # 2. Conservation. + shares = run["qualified_business_income_deduction_person"] + has_amounts = _unit_sum(run, filer * run["qbid_amount"]) > 0 + np.testing.assert_allclose( + _unit_sum(run, shares)[has_amounts], deduction[has_amounts], atol=TOLERANCE + ) + assert (shares[~filer] == 0).all() + is_ia = run["state"] == "IA" + if is_ia.any(): + np.testing.assert_allclose( + _unit_sum(run, run["ia_qbi_deduction"])[is_ia & has_amounts], + (run["ia_fraction"] * deduction)[is_ia & has_amounts], + atol=TOLERANCE, + ) + + # 3. Differential against numpy over the head and spouse. + p = run["p"] + cap = p.max.rate * np.maximum( + 0, run["taxable_income_less_qbid"] - run["adjusted_net_capital_gain"] + ) + pre_floor = np.minimum(_unit_sum(run, filer * run["qbid_amount"]), cap) + reference = pre_floor + if p.deduction_floor.in_effect: + legacy = run["business_is_sstb"] > 0 + qbi = run["qualified_business_income"] + non_sstb = np.where(legacy, 0, qbi) + sstb = run["sstb_qualified_business_income"] + np.where(legacy, qbi, 0) + eligible = non_sstb + sstb * _applicable_rate(run)[run["unit"]] + floor = p.deduction_floor.amount.calc(_unit_sum(run, filer * eligible)) + reference = np.maximum(pre_floor, floor) + np.testing.assert_allclose(deduction, reference, atol=TOLERANCE) + is_mo = run["state"] == "MO" + positive_qbi = np.maximum( + 0, run["qualified_business_income"] + run["sstb_qualified_business_income"] + ) + np.testing.assert_allclose( + run["mo_business_income_deduction"][is_mo], + (run["mo_rate"] * _unit_sum(run, filer * positive_qbi))[is_mo], + atol=TOLERANCE, + ) + + # 4. Bounds. + assert (deduction >= -TOLERANCE).all() + assert (pre_floor <= cap + TOLERANCE).all() + all_members = np.minimum(_unit_sum(run, run["qbid_amount"]), cap) + assert (pre_floor <= all_members + TOLERANCE).all() + + +# Each example is one vectorized batch of tax units, so a few examples cover +# many units; the seeded population runs once. +SETTINGS = dict( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependents_stay_off_the_filers_qbid_2026(units): + # 2026 adds the 199A(i) minimum deduction and the wider phase-in range. + _check(units, 2026) + + +def test_seeded_population_2025(): + _check(_seeded_units(), 2025) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml new file mode 100644 index 00000000000..b7618a888ad --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml @@ -0,0 +1,231 @@ +# 26 USC 199A(a) allows the deduction to the taxpayer, from the qualified +# business income of the taxpayer's own trades or businesses (and, on a joint +# return, the spouse's). A tax unit dependent with business income files their +# own return and takes their own deduction there, so the dependent's income, +# losses and deduction never reach the filer's. + +- name: A dependent's Schedule C income adds nothing to the filer's deduction + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 60_000 + self_employment_income: 20_000 + child: + age: 17 + self_employment_income: 10_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # Each person's QBI is their Schedule C profit less half their + # self-employment tax (x 0.9235 x 0.153 / 2): 20,000 - 1,412.96 and + # 10,000 - 706.48. + qualified_business_income: [18_587.04, 9_293.52] + # 20% of each; the child's 1,858.70 is the child's own, for the child's + # own return. + qbid_amount: [3_717.41, 1_858.70] + # The parent's taxable income (78,587.04 AGI less the 23,625 head of + # household standard deduction) caps it at 10,992.41, which does not bind. + qualified_business_income_deduction: 3_717.41 + qualified_business_income_deduction_person: [3_717.41, 0] + +- name: A dependent's Schedule C loss does not reduce the filer's qualified business income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 60_000 + self_employment_income: 20_000 + child: + age: 17 + self_employment_income: -5_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + qualified_business_income: [18_587.04, -5_000] + # The child's loss is netted on the child's own return, against nothing. + qbid_amount: [3_717.41, 0] + qualified_business_income_deduction: 3_717.41 + +- name: Below the threshold amount the wage limit does not apply, and the dependent's income stays off the return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + qualified_business_income: 100_000 + w2_wages_from_qualified_business: 10_000 + child: + age: 17 + qualified_business_income: 30_000 + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + taxable_income_less_qbid: 150_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [parent, child] + state_code: TX + output: + # 199A(b)(3)(A): taxable income below the 197,300 threshold, so 20% of + # 100,000 without the wage limit. The child's own 20% of 30,000 is not + # the parent's. + qbid_amount: [20_000, 6_000] + qualified_business_income_deduction: 20_000 + +- name: In the phase-in range the wage limit is phased in on the filer's QBI only + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + qualified_business_income: 100_000 + w2_wages_from_qualified_business: 10_000 + child: + age: 17 + qualified_business_income: 30_000 + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + taxable_income_less_qbid: 222_300 + adjusted_net_capital_gain: 0 + households: + household: + members: [parent, child] + state_code: TX + output: + # 199A(b)(3)(B): taxable income 25,000 into the 50,000 phase-in range, so + # half of the excess of 20,000 over the 5,000 wage limit is lost: + # 20,000 - 0.5 x 15,000. The child, with no wages, would keep + # 6,000 - 0.5 x 6,000 on the same taxable income. + qbid_amount: [12_500, 3_000] + qualified_business_income_deduction: 12_500 + +- name: Above the phase-in range the filer's deduction is the full wage limit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + qualified_business_income: 100_000 + w2_wages_from_qualified_business: 10_000 + child: + age: 17 + qualified_business_income: 30_000 + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + taxable_income_less_qbid: 300_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [parent, child] + state_code: TX + output: + # 50% of 10,000 W-2 wages. + qbid_amount: [5_000, 0] + qualified_business_income_deduction: 5_000 + +- name: A joint return nets the spouses' losses against their own income, not a dependent's + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + qualified_business_income: 40_000 + spouse: + age: 45 + qualified_business_income: -10_000 + child: + age: 17 + qualified_business_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + taxable_income_less_qbid: 100_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # The spouses' 10,000 loss leaves 30,000 of the head's 40,000: 20% is + # 6,000. The child's 20,000, netted alone, would give the child 4,000 on + # the child's own return. + qbid_amount: [6_000, 0, 4_000] + qualified_business_income_deduction: 6_000 + qualified_business_income_deduction_person: [6_000, 0, 0] + +- name: A dependent's QBI does not qualify the filer for the minimum deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + qualified_business_income: 500 + child: + age: 17 + qualified_business_income: 600 + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + taxable_income_less_qbid: 50_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [parent, child] + state_code: TX + output: + # 199A(i): the parent's own 500 is under 1,000, so no 400 minimum; + # the deduction is 20% of 500. + qualified_business_income_deduction: 100 + +- name: The filer's own QBI of at least 1,000 still gets the minimum deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + qualified_business_income: 1_500 + child: + age: 17 + qualified_business_income: 600 + tax_units: + tax_unit: + members: [parent, child] + filing_status: HEAD_OF_HOUSEHOLD + taxable_income_less_qbid: 50_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [parent, child] + state_code: TX + output: + # 20% of 1,500 is 300, raised to the 400 minimum. + qualified_business_income_deduction: 400 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml index f68f36871f9..ba86d1e758e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml @@ -15,3 +15,34 @@ state_code: IA output: ia_qbi_deduction: 7_500 + +- name: Spouses share the federal deduction, and a dependent has none + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + qualified_business_income: 50_000 + spouse: + age: 45 + qualified_business_income: 30_000 + child: + age: 17 + qualified_business_income: 10_000 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + taxable_income_less_qbid: 200_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [head, spouse, child] + state_code: IA + output: + # The federal deduction is 20% of the spouses' 80,000, 16,000, shared + # 10,000 and 6,000; Iowa deducts 75% of each share. + qualified_business_income_deduction: 16_000 + qualified_business_income_deduction_person: [10_000, 6_000, 0] + ia_qbi_deduction: [7_500, 4_500, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml index cd875fcae69..2d06b071976 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml @@ -71,3 +71,27 @@ state_code: MO output: mo_business_income_deduction: 18_587.04 + +- name: A dependent's business income is on the dependent's own return + period: 2025 + input: + people: + head: + age: 45 + qualified_business_income: 30_000 + spouse: + age: 45 + qualified_business_income: 20_000 + child: + age: 17 + qualified_business_income: 10_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: MO + output: + # 20% of the spouses' 50,000. + mo_business_income_deduction: 10_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py index 937bf148e48..52175ed7718 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py @@ -133,14 +133,23 @@ def full_cap(w2_wages, b_property): # Section 1.199A-1(d)(2) orders SSTB exclusion before loss netting, # then applies wage/property limits to each positive component. - # Allocate all eligible tax-unit losses proportionately to positive - # QBI; a losing business supplies no wages/property to another one. + # Allocate all eligible losses on the return proportionately to + # positive QBI; a losing business supplies no wages/property to + # another one. The head's and spouse's businesses are netted together; + # a tax unit dependent's businesses are on the dependent's own return, + # so they are netted alone and never offset the filer's. eligible_sstb_qbi = sstb_qbi * applicable_rate positive_non_sstb = max_(0, non_sstb_qbi_final) positive_sstb = max_(0, eligible_sstb_qbi) - total_positive = person.tax_unit.sum(positive_non_sstb + positive_sstb) - total_losses = person.tax_unit.sum( - max_(0, -non_sstb_qbi_final) + max_(0, -eligible_sstb_qbi) + own_positive = positive_non_sstb + positive_sstb + own_losses = max_(0, -non_sstb_qbi_final) + max_(0, -eligible_sstb_qbi) + is_dependent = person("is_tax_unit_dependent", period) + filer = ~is_dependent + total_positive = where( + is_dependent, own_positive, person.tax_unit.sum(own_positive * filer) + ) + total_losses = where( + is_dependent, own_losses, person.tax_unit.sum(own_losses * filer) ) retained_share = np.divide( max_(0, total_positive - total_losses), diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py index ee7553d3e16..2703ee6f05c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py @@ -13,11 +13,16 @@ class qualified_business_income_deduction(Variable): ) def formula(tax_unit, period, parameters): - # compute sum of QBID amounts for each person in TaxUnit following - # logic in 2018 IRS Publication 535, Worksheet 12-A, line 16 + # compute sum of QBID amounts for the head and spouse following + # logic in 2018 IRS Publication 535, Worksheet 12-A, line 16. The + # deduction is the taxpayer's, from their own (and, on a joint + # return, their spouse's) qualified business income; a tax unit + # dependent with business income takes their deduction on their + # own return. person = tax_unit.members + filer = ~person("is_tax_unit_dependent", period) qbid_amt = person("qbid_amount", period) - uncapped_qbid = tax_unit.sum(qbid_amt) + uncapped_qbid = tax_unit.sum(qbid_amt * filer) # apply taxinc cap at the TaxUnit level following logic # in 2018 IRS Publication 535, Worksheet 12-A, lines 32-37 taxinc_less_qbid = tax_unit("taxable_income_less_qbid", period) @@ -42,7 +47,7 @@ def formula(tax_unit, period, parameters): legacy_sstb, qbi, 0 ) total_qbi = tax_unit.sum( - non_sstb + sstb * tax_unit.project(applicable_rate) + (non_sstb + sstb * tax_unit.project(applicable_rate)) * filer ) floor = p.deduction_floor.amount.calc(total_qbi) return max_(pre_floor_qbid, floor) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py index 85d58366cfb..f6a653958db 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py @@ -13,9 +13,12 @@ class qualified_business_income_deduction_person(Variable): ) def formula(person, period, parameters): - # Allocate the business income deduction to each person in the tax unit - # based on their share of per cap qualified business income deduction amount - qbid_amt = person("qbid_amount", period) + # Allocate the business income deduction to the head and spouse + # based on their share of per cap qualified business income deduction + # amount. The deduction is theirs alone, so a tax unit dependent's + # share is zero. + filer = ~person("is_tax_unit_dependent", period) + qbid_amt = person("qbid_amount", period) * filer total_qbid_amount = person.tax_unit.sum(qbid_amt) total_deduction_amount = person.tax_unit( "qualified_business_income_deduction", period diff --git a/policyengine_us/variables/gov/states/ia/tax/income/including_married_filing_separately/deductions/ia_qbi_deduction.py b/policyengine_us/variables/gov/states/ia/tax/income/including_married_filing_separately/deductions/ia_qbi_deduction.py index cf42e30ee84..4ebc02f8fa0 100644 --- a/policyengine_us/variables/gov/states/ia/tax/income/including_married_filing_separately/deductions/ia_qbi_deduction.py +++ b/policyengine_us/variables/gov/states/ia/tax/income/including_married_filing_separately/deductions/ia_qbi_deduction.py @@ -18,13 +18,9 @@ class ia_qbi_deduction(Variable): defined_for = StateCode.IA def formula(person, period, parameters): - fed_indv_qbid = person("qbid_amount", period) - tax_unit = person.tax_unit - fed_unit_qbid = tax_unit("qualified_business_income_deduction", period) - # avoid divide-by-zero warnings by not using where() function - reduction_factor = np.ones_like(fed_indv_qbid) - mask = fed_indv_qbid > 0 - reduction_factor[mask] = fed_unit_qbid[mask] / fed_indv_qbid[mask] - fed_qbid = fed_indv_qbid * reduction_factor + # Each spouse's share of the federal deduction, which sums to the tax + # unit's deduction over the head and spouse; a tax unit dependent has + # no share. + fed_qbid = person("qualified_business_income_deduction_person", period) p = parameters(period).gov.states.ia.tax.income return fed_qbid * p.deductions.qualified_business_income.fraction diff --git a/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py b/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py index 4665689d3ac..36a6ada8578 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py @@ -16,8 +16,12 @@ class mo_business_income_deduction(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.mo.tax.income.deductions.business_income person = tax_unit.members + # The head's and spouse's business income; a tax unit dependent's is + # on the dependent's own return. + filer = ~person("is_tax_unit_dependent", period) total_qualified_business_income = tax_unit.sum( - max_( + filer + * max_( 0, person("qualified_business_income", period) + person("sstb_qualified_business_income", period), From 97041ee8160fdfd712259706fa707ab29abfb422 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 08:46:33 -0400 Subject: [PATCH 2/2] Address review r1 on the QBID dependents fix - qualified_business_income_deduction_person shares a deduction that comes only from the 199A(i) minimum (every per-person amount zero) in proportion to the spouses' positive QBI, or to the head, so the shares always sum to the deduction and Iowa no longer drops it. - Tests assert the per-person shares rather than a dependent's qbid_amount, which is figured on the filer's taxable income. - The Iowa case checks ia_taxable_income_joint (main: 59,500, taking the 13,500 deduction three times; branch: 88,000). - The property test drops its vacuous bound and the has_amounts carve-out, and says which checks recompute the aggregation. Co-Authored-By: Claude Opus 5.5 --- .../core/test_qbid_dependents_invariants.py | 45 ++++++++--------- .../deductions/qbid/qbid_dependents.yaml | 24 ++++----- ...fied_business_income_deduction_person.yaml | 50 +++++++++++++++++++ .../ia/tax/income/ia_qbi_deduction.yaml | 13 +++++ ...lified_business_income_deduction_person.py | 32 +++++++++--- 5 files changed, 119 insertions(+), 45 deletions(-) diff --git a/policyengine_us/tests/core/test_qbid_dependents_invariants.py b/policyengine_us/tests/core/test_qbid_dependents_invariants.py index f2be2df95ab..52f55386bad 100644 --- a/policyengine_us/tests/core/test_qbid_dependents_invariants.py +++ b/policyengine_us/tests/core/test_qbid_dependents_invariants.py @@ -15,19 +15,21 @@ inputs as drawn and with them set to zero. For every tax unit: 1. The dependents' business inputs never change the filer's deduction, its - per-person shares, the head's and spouse's per-person amounts, the - Missouri business income deduction or the Iowa deduction. -2. Conservation: the members' shares sum to the deduction, a dependent's - share is zero, and the members' Iowa deductions sum to Iowa's fraction of - the federal deduction. -3. Differential: the deduction equals an independent numpy computation of - the lesser of the head's and spouse's per-person amounts and 20% of - taxable income less net capital gain, raised from 2026 to the 199A(i) - minimum on the head's and spouse's eligible QBI; Missouri's equals its - rate times the head's and spouse's positive QBI. -4. Bounds: the deduction is never negative, never above 20% of taxable - income less net capital gain (before the minimum), and never above the - previous all-member sum. + per-person shares, the head's and spouse's per-person amounts (so the + loss netting is the spouses' alone), the Missouri business income + deduction or the Iowa deduction. This is the property main violated. +2. Conservation: the members' shares sum to the deduction (including one + that comes only from the 199A(i) minimum), a dependent's share is zero, + and the members' Iowa deductions sum to Iowa's fraction of the federal + deduction. +3. Aggregation: the deduction is the lesser of the sum of the head's and + spouse's per-person amounts and 20% of taxable income less net capital + gain, raised from 2026 to the 199A(i) minimum on their eligible QBI, + recomputed in numpy from the model's per-person values. Missouri's + deduction is its rate times the head's and spouse's positive QBI. The + per-person amounts themselves are pinned by the YAML cases. +4. Bounds: the deduction is never negative, and never above 20% of taxable + income less net capital gain, or the minimum when that is larger. """ import numpy as np @@ -251,26 +253,24 @@ def _check(units, year): # 2. Conservation. shares = run["qualified_business_income_deduction_person"] - has_amounts = _unit_sum(run, filer * run["qbid_amount"]) > 0 - np.testing.assert_allclose( - _unit_sum(run, shares)[has_amounts], deduction[has_amounts], atol=TOLERANCE - ) + np.testing.assert_allclose(_unit_sum(run, shares), deduction, atol=TOLERANCE) assert (shares[~filer] == 0).all() is_ia = run["state"] == "IA" if is_ia.any(): np.testing.assert_allclose( - _unit_sum(run, run["ia_qbi_deduction"])[is_ia & has_amounts], - (run["ia_fraction"] * deduction)[is_ia & has_amounts], + _unit_sum(run, run["ia_qbi_deduction"])[is_ia], + (run["ia_fraction"] * deduction)[is_ia], atol=TOLERANCE, ) - # 3. Differential against numpy over the head and spouse. + # 3. Aggregation over the head and spouse, recomputed in numpy. p = run["p"] cap = p.max.rate * np.maximum( 0, run["taxable_income_less_qbid"] - run["adjusted_net_capital_gain"] ) pre_floor = np.minimum(_unit_sum(run, filer * run["qbid_amount"]), cap) reference = pre_floor + ceiling = cap if p.deduction_floor.in_effect: legacy = run["business_is_sstb"] > 0 qbi = run["qualified_business_income"] @@ -279,6 +279,7 @@ def _check(units, year): eligible = non_sstb + sstb * _applicable_rate(run)[run["unit"]] floor = p.deduction_floor.amount.calc(_unit_sum(run, filer * eligible)) reference = np.maximum(pre_floor, floor) + ceiling = np.maximum(cap, floor) np.testing.assert_allclose(deduction, reference, atol=TOLERANCE) is_mo = run["state"] == "MO" positive_qbi = np.maximum( @@ -292,9 +293,7 @@ def _check(units, year): # 4. Bounds. assert (deduction >= -TOLERANCE).all() - assert (pre_floor <= cap + TOLERANCE).all() - all_members = np.minimum(_unit_sum(run, run["qbid_amount"]), cap) - assert (pre_floor <= all_members + TOLERANCE).all() + assert (deduction <= ceiling + TOLERANCE).all() # Each example is one vectorized batch of tax units, so a few examples cover diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml index b7618a888ad..e3fb39aa1d8 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_dependents.yaml @@ -28,9 +28,7 @@ # self-employment tax (x 0.9235 x 0.153 / 2): 20,000 - 1,412.96 and # 10,000 - 706.48. qualified_business_income: [18_587.04, 9_293.52] - # 20% of each; the child's 1,858.70 is the child's own, for the child's - # own return. - qbid_amount: [3_717.41, 1_858.70] + # 20% of the parent's; the child's is for the child's own return. # The parent's taxable income (78,587.04 AGI less the 23,625 head of # household standard deduction) caps it at 10,992.41, which does not bind. qualified_business_income_deduction: 3_717.41 @@ -57,9 +55,9 @@ state_code: TX output: qualified_business_income: [18_587.04, -5_000] - # The child's loss is netted on the child's own return, against nothing. - qbid_amount: [3_717.41, 0] + # The child's loss is netted on the child's own return, not the parent's. qualified_business_income_deduction: 3_717.41 + qualified_business_income_deduction_person: [3_717.41, 0] - name: Below the threshold amount the wage limit does not apply, and the dependent's income stays off the return period: 2025 @@ -85,10 +83,9 @@ state_code: TX output: # 199A(b)(3)(A): taxable income below the 197,300 threshold, so 20% of - # 100,000 without the wage limit. The child's own 20% of 30,000 is not - # the parent's. - qbid_amount: [20_000, 6_000] + # 100,000 without the wage limit. The child's 30,000 is not the parent's. qualified_business_income_deduction: 20_000 + qualified_business_income_deduction_person: [20_000, 0] - name: In the phase-in range the wage limit is phased in on the filer's QBI only period: 2025 @@ -115,10 +112,9 @@ output: # 199A(b)(3)(B): taxable income 25,000 into the 50,000 phase-in range, so # half of the excess of 20,000 over the 5,000 wage limit is lost: - # 20,000 - 0.5 x 15,000. The child, with no wages, would keep - # 6,000 - 0.5 x 6,000 on the same taxable income. - qbid_amount: [12_500, 3_000] + # 20,000 - 0.5 x 15,000. qualified_business_income_deduction: 12_500 + qualified_business_income_deduction_person: [12_500, 0] - name: Above the phase-in range the filer's deduction is the full wage limit period: 2025 @@ -144,8 +140,8 @@ state_code: TX output: # 50% of 10,000 W-2 wages. - qbid_amount: [5_000, 0] qualified_business_income_deduction: 5_000 + qualified_business_income_deduction_person: [5_000, 0] - name: A joint return nets the spouses' losses against their own income, not a dependent's period: 2025 @@ -173,9 +169,7 @@ state_code: TX output: # The spouses' 10,000 loss leaves 30,000 of the head's 40,000: 20% is - # 6,000. The child's 20,000, netted alone, would give the child 4,000 on - # the child's own return. - qbid_amount: [6_000, 0, 4_000] + # 6,000. The child's 20,000 is netted on the child's own return. qualified_business_income_deduction: 6_000 qualified_business_income_deduction_person: [6_000, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction_person.yaml index 271619a00de..6ee99bf06af 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction_person.yaml @@ -45,3 +45,53 @@ qualified_business_income_deduction: 0 output: qualified_business_income_deduction_person: [0, 0] + +- name: A deduction from the 199A(i) minimum alone goes to the filer with the income + period: 2026 + input: + people: + filer: + age: 40 + qualified_business_income: 5_000 + tax_units: + tax_unit: + members: [filer] + filing_status: SINGLE + taxable_income_less_qbid: 300_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [filer] + state_code: TX + output: + # Above the 276,750 end of the phase-in range with no W-2 wages or + # property, the wage limit leaves no per-person amount; the 400 minimum + # still applies to the 5,000 of QBI. + qbid_amount: 0 + qualified_business_income_deduction: 400 + qualified_business_income_deduction_person: 400 + +- name: A deduction from the 199A(i) minimum alone is shared by the spouses' QBI + period: 2026 + input: + people: + head: + age: 45 + qualified_business_income: 3_000 + spouse: + age: 45 + qualified_business_income: 1_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + taxable_income_less_qbid: 600_000 + adjusted_net_capital_gain: 0 + households: + household: + members: [head, spouse] + state_code: TX + output: + qualified_business_income_deduction: 400 + # 400 shared 3:1. + qualified_business_income_deduction_person: [300, 100] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml index ba86d1e758e..c88c3b41307 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_qbi_deduction.yaml @@ -24,12 +24,23 @@ head: age: 45 qualified_business_income: 50_000 + # Iowa net income, with the other deductions set aside, so the joint + # taxable income shows the QBI deduction alone. + ia_net_income: 100_000 + ia_fedtax_deduction: 0 + ia_basic_deduction_joint: 0 spouse: age: 45 qualified_business_income: 30_000 + ia_net_income: 0 + ia_fedtax_deduction: 0 + ia_basic_deduction_joint: 0 child: age: 17 qualified_business_income: 10_000 + ia_net_income: 0 + ia_fedtax_deduction: 0 + ia_basic_deduction_joint: 0 tax_units: tax_unit: members: [head, spouse, child] @@ -46,3 +57,5 @@ qualified_business_income_deduction: 16_000 qualified_business_income_deduction_person: [10_000, 6_000, 0] ia_qbi_deduction: [7_500, 4_500, 0] + # The joint return deducts the 12,000 once: 100,000 - 12,000. + ia_taxable_income_joint: [88_000, 0, 0] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py index f6a653958db..490ac4248b4 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction_person.py @@ -17,13 +17,31 @@ def formula(person, period, parameters): # based on their share of per cap qualified business income deduction # amount. The deduction is theirs alone, so a tax unit dependent's # share is zero. + tax_unit = person.tax_unit filer = ~person("is_tax_unit_dependent", period) qbid_amt = person("qbid_amount", period) * filer - total_qbid_amount = person.tax_unit.sum(qbid_amt) - total_deduction_amount = person.tax_unit( - "qualified_business_income_deduction", period + total_qbid_amount = tax_unit.sum(qbid_amt) + deduction = tax_unit("qualified_business_income_deduction", period) + # From 2026 the 199A(i) minimum can give a deduction when every + # per-person amount is zero, as when the wage limit removes it; share + # that in proportion to positive qualified business income, or give + # it to the head. + qbi = filer * max_( + 0, + person("qualified_business_income", period) + + person("sstb_qualified_business_income", period), ) - deduction_fraction = np.zeros_like(total_qbid_amount) - mask = total_qbid_amount != 0 - deduction_fraction[mask] = qbid_amt[mask] / total_qbid_amount[mask] - return deduction_fraction * total_deduction_amount + total_qbi = tax_unit.sum(qbi) + weight = where( + total_qbid_amount > 0, + qbid_amt, + where(total_qbi > 0, qbi, person("is_tax_unit_head", period)), + ) + total_weight = tax_unit.sum(weight) + fraction = np.divide( + weight, + total_weight, + out=np.zeros_like(total_weight, dtype=float), + where=total_weight > 0, + ) + return fraction * deduction