From 4466c20a4177f8b1dfd2b90a2fb6528ab99ffb5b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 14:32:19 -0400 Subject: [PATCH 1/4] Add the section 911(a)(1) exclusion to NIIT modified AGI 26 U.S.C. 1411(d) defines modified adjusted gross income as AGI increased by the foreign earned income excluded under section 911(a)(1), less the deductions and exclusions section 911(d)(6) disallows for it. niit_magi added only the estate and trust code H change, so a filer whose MAGI crossed the threshold only through the exclusion paid no NIIT. The add-back is a new tax-unit variable, niit_magi_section_911_addition, defaulting to foreign_earned_income_exclusion. Section 1411(d) and the Form 8960 Line 13 MAGI Worksheet (line 2a: Form 2555 line 42) leave out the section 911(a)(2) housing exclusion and the housing deduction, which foreign_earned_income_exclusion includes (Form 2555 lines 45 and 50), so a filer with housing amounts can enter the worksheet amount directly. niit_magi floors the addition at zero ("the excess (if any)"). Tests: YAML cases for the reproducer, a case below the threshold, a partly binding excess, joint and separate filers, an entered addition without housing amounts and a negative entered amount; Hypothesis property and differential tests against the pre-change formula run as a reform. Co-Authored-By: Claude Opus 5.5 --- ...fix-niit-magi-section-911-addback.fixed.md | 1 + .../net_investment_income_tax.yaml | 149 +++++++ .../gov/irs/tax/federal_income/niit_magi.yaml | 91 ++++ .../tests/test_niit_section_911_magi.py | 390 ++++++++++++++++++ .../foreign_earned_income.py | 2 +- .../gov/irs/tax/federal_income/niit_magi.py | 21 +- .../niit_magi_section_911_addition.py | 34 ++ 7 files changed, 683 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fix-niit-magi-section-911-addback.fixed.md create mode 100644 policyengine_us/tests/test_niit_section_911_magi.py create mode 100644 policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py diff --git a/changelog.d/fix-niit-magi-section-911-addback.fixed.md b/changelog.d/fix-niit-magi-section-911-addback.fixed.md new file mode 100644 index 00000000000..e02cc011b05 --- /dev/null +++ b/changelog.d/fix-niit-magi-section-911-addback.fixed.md @@ -0,0 +1 @@ +Add the foreign earned income excluded under 26 U.S.C. 911(a)(1) to modified adjusted gross income for the net investment income tax, as 26 U.S.C. 1411(d) requires, through a new niit_magi_section_911_addition variable that a filer whose exclusion includes housing amounts can enter directly. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/net_investment_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/net_investment_income_tax.yaml index c8850912ea2..d4983683baa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/net_investment_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/net_investment_income_tax.yaml @@ -339,3 +339,152 @@ niit_magi: 255_000 net_investment_income: 15_000 net_investment_income_tax: 190 + +- name: Section 911 exclusion lifts a single filer's MAGI over the threshold + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 100_000 + households: + household: + members: [person1] + state_code: TX + output: + # MAGI 200,000 + 100,000 = 300,000; excess over 200,000 is 100,000. + # 3.8% x min(50,000, 100,000) = 1,900. + niit_magi: 300_000 + net_investment_income: 50_000 + net_investment_income_tax: 1_900 + +- name: Section 911 exclusion that leaves MAGI below the threshold + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 90_000 + households: + household: + members: [person1] + state_code: TX + output: + # MAGI 100,000 + 90,000 = 190,000, below the 200,000 threshold. + niit_magi: 190_000 + net_investment_income_tax: 0 + +- name: Section 911 exclusion where the MAGI excess limits the tax base + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 120_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 60_000 + households: + household: + members: [person1] + state_code: TX + output: + # MAGI 170,000 + 60,000 = 230,000; 3.8% x min(50,000, 30,000) = 1,140. + niit_magi: 230_000 + net_investment_income_tax: 1_140 + +- name: Section 911 exclusion lifts a joint return's MAGI over the threshold + period: 2025 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + qualified_dividend_income: 40_000 + spouse: + age: 45 + is_tax_unit_spouse: true + employment_income: 50_000 + tax_units: + tax_unit: + members: [head, spouse] + foreign_earned_income_exclusion: 60_000 + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # AGI 240,000 is below the 250,000 joint threshold; MAGI 300,000 is + # 50,000 above it. 3.8% x min(40,000, 50,000) = 1,520. + filing_status: JOINT + adjusted_gross_income: 240_000 + niit_magi: 300_000 + net_investment_income_tax: 1_520 + +- name: Section 911 exclusion on a separate return uses the 125,000 threshold + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 100_000 + taxable_interest_income: 20_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SEPARATE + foreign_earned_income_exclusion: 30_000 + households: + household: + members: [person1] + state_code: TX + output: + # MAGI 120,000 + 30,000 = 150,000; 3.8% x min(20,000, 25,000) = 760. + niit_magi: 150_000 + net_investment_income_tax: 760 + +- name: An entered section 911 addition without housing amounts sets the tax + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 100_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + # Form 2555 line 45 is 100,000, of which 30,000 is the housing + # exclusion. Section 1411(d) adds back the other 70,000. + foreign_earned_income_exclusion: 100_000 + niit_magi_section_911_addition: 70_000 + households: + household: + members: [person1] + state_code: TX + output: + # MAGI 150,000 + 70,000 = 220,000; 3.8% x min(50,000, 20,000) = 760. + # The default (the full 100,000) would give 1,900. + niit_magi: 220_000 + net_investment_income_tax: 760 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml index cf878ddd3ff..f325f531755 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml @@ -70,3 +70,94 @@ output: adjusted_gross_income: 160_000 niit_magi: 160_000 + +- name: The section 911(a)(1) exclusion raises MAGI by the excluded amount + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 100_000 + households: + household: + members: [person1] + state_code: TX + output: + # 26 U.S.C. 1411(d); Form 8960 Line 13 MAGI Worksheet: line 1 AGI + # 200,000 plus line 2c 100,000. + adjusted_gross_income: 200_000 + niit_magi_section_911_addition: 100_000 + niit_magi: 300_000 + +- name: The section 911 addition and positive code H both raise MAGI + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + estate_income: 10_000 + estate_income_net_investment_income_adjustment: 4_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 20_000 + households: + household: + members: [person1] + state_code: TX + output: + adjusted_gross_income: 160_000 + niit_magi: 160_000 + 4_000 + 20_000 + +- name: An entered worksheet amount replaces the default, leaving out housing amounts + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + # Form 2555 line 45 is 100,000: a 70,000 foreign earned income + # exclusion (line 42) and a 30,000 housing exclusion (line 36). + # Section 1411(d) adds back only the line 42 amount. + foreign_earned_income_exclusion: 100_000 + niit_magi_section_911_addition: 70_000 + households: + household: + members: [person1] + state_code: TX + output: + adjusted_gross_income: 200_000 + niit_magi: 270_000 + +- name: A negative entered addition leaves MAGI at AGI + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + tax_units: + tax_unit: + members: [person1] + niit_magi_section_911_addition: -5_000 + households: + household: + members: [person1] + state_code: TX + output: + # Section 1411(d) adds "the excess (if any)", never a negative amount. + niit_magi: 150_000 diff --git a/policyengine_us/tests/test_niit_section_911_magi.py b/policyengine_us/tests/test_niit_section_911_magi.py new file mode 100644 index 00000000000..b32c853b503 --- /dev/null +++ b/policyengine_us/tests/test_niit_section_911_magi.py @@ -0,0 +1,390 @@ +"""Property and differential tests for the section 911 addition to NIIT MAGI. + +26 U.S.C. 1411(d): "the term 'modified adjusted gross income' means adjusted +gross income increased by the excess of— (1) the amount excluded from gross +income under section 911(a)(1), over (2) the amount of any deductions (taken +into account in computing adjusted gross income) or exclusions disallowed +under section 911(d)(6) with respect to the amounts described in paragraph +(1)." The Form 8960 Line 13 MAGI Worksheet enters that excess on line 2c. + +`niit_magi_section_911_addition` is that excess. It defaults to +`foreign_earned_income_exclusion` and can be entered directly, for a filer +whose exclusion includes housing amounts that section 1411(d) leaves out. + +Properties that hold for every household: + +1. Nothing added changes nothing, bit for bit. With no exclusion, or an + entered addition of zero or less, AGI, `niit_magi`, + `net_investment_income_tax` and income tax equal those of the formula + before the section 911 addition (AGI plus the estate and trust code H + change), run as a reform on the same households. A household without an + exclusion also gets the same results whatever the other households in + the batch exclude. +2. MAGI is AGI plus the positive code H change plus the addition, and the + addition is the exclusion, or the entered amount, floored at zero. +3. MAGI and the tax never fall as the excluded amount rises. +4. The tax is 3.8% of the lesser of net investment income and the excess of + MAGI over the threshold of section 1411(b) ($250,000 joint, $125,000 + separate, $200,000 otherwise, not indexed), computed here independently. + +Households live in Texas. +""" + +from functools import lru_cache + +import numpy as np +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +RATE = 0.038 +THRESHOLD = { + "SINGLE": 200_000, + "HEAD_OF_HOUSEHOLD": 200_000, + "JOINT": 250_000, + "SEPARATE": 125_000, +} +STATUSES = list(THRESHOLD) +YEARS = [2018, 2022, 2025, 2026] + +OUTPUTS = [ + "filing_status", + "adjusted_gross_income", + "foreign_earned_income_exclusion", + "niit_magi_section_911_addition", + "niit_magi", + "net_investment_income", + "net_investment_income_tax", + "income_tax_before_refundable_credits", + "income_tax", +] +# Every output other than the exclusion and the addition themselves, which +# the households set. +UNCHANGED_WITHOUT_EXCLUSION = [ + "adjusted_gross_income", + "niit_magi", + "net_investment_income", + "net_investment_income_tax", + "income_tax_before_refundable_credits", + "income_tax", +] + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["qualified_dividends"]}, + "taxable_interest_income": {year: h["interest"]}, + "long_term_capital_gains": {year: h["long_term_gains"]}, + "estate_income": {year: h["estate_income"]}, + "estate_income_net_investment_income_adjustment": {year: h["code_h"]}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = { + "age": {year: 45}, + "employment_income": {year: h["spouse_wages"]}, + } + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["status"] == "HEAD_OF_HOUSEHOLD": + child = f"child_{i}" + people[child] = {"age": {year: 17}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_unit = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single). + "filing_status": {year: h["status"]}, + "foreign_earned_income_exclusion": {year: h["exclusion"]}, + } + if h["entered_addition"] is not None: + tax_unit["niit_magi_section_911_addition"] = {year: h["entered_addition"]} + tax_units[f"tax_unit_{i}"] = tax_unit + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def before_the_section_911_addition(): + """A reform restoring `niit_magi` as it was before this change.""" + from policyengine_core.reforms import Reform + + from policyengine_us.model_api import ( + USD, + YEAR, + TaxUnit, + Variable, + ) + + class niit_magi(Variable): + value_type = float + entity = TaxUnit + label = "NIIT MAGI before the section 911 addition" + unit = USD + definition_period = YEAR + + def formula(tax_unit, period, parameters): + person = tax_unit.members + not_dependent = ~person("is_tax_unit_dependent", period) + estate_magi_adjustment = tax_unit.sum( + not_dependent * person("estate_income_niit_magi_adjustment", period) + ) + return tax_unit("adjusted_gross_income", period) + estate_magi_adjustment + + class reform(Reform): + def apply(self): + self.update_variable(niit_magi) + + return reform + + +@lru_cache(maxsize=None) +def system_before_the_section_911_addition(): + """Built once: constructing a reformed system takes seconds.""" + return CountryTaxBenefitSystem(reform=before_the_section_911_addition()) + + +def calculate_batch(households, year, before): + simulation = Simulation( + situation=build_situation(households, year), + **( + {"tax_benefit_system": system_before_the_section_911_addition()} + if before + else {} + ), + ) + results = {} + for v in OUTPUTS: + values = simulation.calculate(v, year) + results[v] = ( + values.decode_to_str() if v == "filing_status" else np.asarray(values) + ) + return results + + +def calculate(households, year, before=False): + """Households with and without an entered addition, as separate batches. + + An input given to only some tax units in a batch sets the rest to the + default (zero), not to the value the formula would compute, so mixing + them would hide the default. + """ + entered = np.array([h["entered_addition"] is not None for h in households]) + results = {} + for group in [entered, ~entered]: + if not group.any(): + continue + batch = calculate_batch( + [h for h, g in zip(households, group) if g], year, before + ) + for v, values in batch.items(): + if v not in results: + results[v] = np.empty(len(households), dtype=values.dtype) + if v == "filing_status": + results[v] = np.empty(len(households), dtype=object) + results[v][group] = values + return results + + +def with_exclusion(households, exclusion): + """The same households with the exclusion replaced or transformed.""" + change = exclusion if callable(exclusion) else lambda _: exclusion + return [{**h, "exclusion": change(h["exclusion"])} for h in households] + + +def tolerance(*amounts): + """One cent, plus the rounding of single-precision arithmetic. + + The model stores amounts as 32-bit floats, which carry about seven + significant digits. + """ + return 0.01 + 5e-7 * np.max(np.abs(amounts), axis=0) + + +def expected_addition(household): + entered = household["entered_addition"] + amount = household["exclusion"] if entered is None else entered + return max(0, amount) + + +def assert_invariants(households, year): + law = calculate(households, year) + before = calculate(households, year, before=True) + unstacked = calculate(with_exclusion(households, 0), year) + more_excluded = calculate(with_exclusion(households, lambda e: 1.5 * e + 500), year) + assert list(law["filing_status"]) == [h["status"] for h in households] + agi = law["adjusted_gross_income"] + addition = np.array([expected_addition(h) for h in households]) + nothing_added = addition == 0 + + # 1. Nothing excluded changes nothing, bit for bit: against the formula + # before the section 911 addition, and against the same households in a + # batch where no one excludes anything. + for v in UNCHANGED_WITHOUT_EXCLUSION: + assert np.array_equal(law[v][nothing_added], before[v][nothing_added]), v + no_exclusion = np.array( + [h["exclusion"] == 0 and h["entered_addition"] is None for h in households] + ) + for v in OUTPUTS[1:]: + assert np.array_equal(law[v][no_exclusion], unstacked[v][no_exclusion]), v + assert not law["niit_magi_section_911_addition"][no_exclusion].any() + + # 2. MAGI is AGI plus the positive code H change plus the addition. + code_h = np.array([max(0, h["code_h"]) for h in households]) + magi = agi + code_h + addition + slack = tolerance(magi, more_excluded["niit_magi"]) + assert (np.abs(law["niit_magi"] - magi) <= slack).all() + assert (np.abs(law["niit_magi"] - before["niit_magi"] - addition) <= slack).all() + entered = np.array([h["entered_addition"] is not None for h in households]) + assert ( + np.abs(law["niit_magi_section_911_addition"][~entered] - addition[~entered]) + <= slack[~entered] + ).all() + + # 3. MAGI and the tax never fall as the excluded amount rises. Rounding + # is monotone, so this holds exactly. + for v in ["niit_magi", "net_investment_income_tax"]: + assert (more_excluded[v] >= law[v]).all(), v + assert (law[v] >= unstacked[v]).all(), v + + # 4. The tax from the statute, computed independently of the model's + # formula and parameters. + threshold = np.array([THRESHOLD[h["status"]] for h in households]) + net_investment_income = law["net_investment_income"] + niit = RATE * np.minimum( + np.maximum(0, net_investment_income), np.maximum(0, magi - threshold) + ) + assert (np.abs(law["net_investment_income_tax"] - niit) <= slack).all() + assert (law["net_investment_income_tax"] >= 0).all() + assert ( + law["net_investment_income_tax"] + <= RATE * np.maximum(0, net_investment_income) + slack + ).all() + return law, before + + +def household(status, wages, exclusion, **amounts): + return { + "status": status, + "wages": wages, + "spouse_wages": amounts.get("spouse_wages", 0), + "qualified_dividends": amounts.get("qualified_dividends", 0), + "interest": amounts.get("interest", 0), + "long_term_gains": amounts.get("long_term_gains", 0), + "estate_income": amounts.get("estate_income", 0), + "code_h": amounts.get("code_h", 0), + "exclusion": exclusion, + "entered_addition": amounts.get("entered_addition"), + } + + +GRID = [ + household( + status, + wages, + exclusion, + spouse_wages=40_000 if status == "JOINT" else 0, + qualified_dividends=dividends, + long_term_gains=gains, + estate_income=estate_income, + code_h=code_h, + entered_addition=entered, + ) + for status in STATUSES + for wages in (0, 120_000, 600_000) + for dividends, gains in ((0, 0), (50_000, 0), (10_000, 120_000)) + for estate_income, code_h in ((0, 0), (12_000, 5_000)) + for exclusion in (0, 1, 60_000, 130_000, 260_000) + for entered in (None, 40_000) +] + [ + # An entered negative amount, and an entered zero beside a positive + # exclusion. + household( + "SINGLE", 150_000, 0, qualified_dividends=50_000, entered_addition=-5_000 + ), + household( + "SINGLE", 150_000, 100_000, qualified_dividends=50_000, entered_addition=0 + ), +] + + +@pytest.mark.parametrize("year", YEARS) +def test_grid_invariants(year): + law, before = assert_invariants(GRID, year) + # The grid reaches filers whose tax the addition raises and filers it + # does not. + raised = law["net_investment_income_tax"] > before["net_investment_income_tax"] + assert raised.any() + assert (~raised & (law["niit_magi_section_911_addition"] > 0)).any() + + +def test_review_example(): + # 2025, single, $150,000 of wages, $50,000 of qualified dividends and a + # $100,000 section 911(a)(1) exclusion. MAGI is $300,000, $100,000 above + # the threshold, so the tax is 3.8% of $50,000. + example = household("SINGLE", 150_000, 100_000, qualified_dividends=50_000) + law, before = assert_invariants([example], 2025) + assert law["adjusted_gross_income"][0] == 200_000 + assert law["niit_magi"][0] == 300_000 + assert law["net_investment_income_tax"][0] == pytest.approx(1_900, abs=0.01) + # Before the change, MAGI stayed at AGI and there was no tax. + assert before["niit_magi"][0] == 200_000 + assert before["net_investment_income_tax"][0] == 0 + + +household_strategy = st.fixed_dictionaries( + { + "status": st.sampled_from(STATUSES), + "wages": st.integers(0, 900_000), + "spouse_wages": st.one_of(st.just(0), st.integers(1, 400_000)), + "qualified_dividends": st.one_of(st.just(0), st.integers(1, 300_000)), + "interest": st.one_of(st.just(0), st.integers(1, 100_000)), + "long_term_gains": st.one_of(st.just(0), st.integers(-50_000, 1_000_000)), + "estate_income": st.one_of(st.just(0), st.integers(1, 100_000)), + "code_h": st.one_of(st.just(0), st.integers(-50_000, 50_000)), + "exclusion": st.one_of(st.just(0), st.integers(1, 300_000)), + "entered_addition": st.one_of( + st.none(), st.just(0), st.integers(-20_000, 300_000) + ), + } +) + +SETTINGS = dict( + max_examples=10, + deadline=None, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(household_strategy, min_size=1, max_size=25), + st.sampled_from(YEARS), +) +def test_random_households_keep_the_invariants(households, year): + assert_invariants(households, year) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py index 44dbb48992b..601559b2e36 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income.py @@ -6,7 +6,7 @@ class foreign_earned_income_exclusion(Variable): entity = TaxUnit label = "Foreign earned income exclusion" unit = USD - documentation = "Income earned and any housing expense in foreign countries that is excluded from adjusted gross income under 26 U.S. Code § 911. Other income inputs are the amounts left after the exclusion. Section 911(f) adds this amount back to set the tax rates on that income: line 2c of the Foreign Earned Income Tax Worksheet (Form 2555 lines 45 and 50, less deductions disallowed because they relate to the excluded income)." + documentation = "Income earned and any housing expense in foreign countries that is excluded from adjusted gross income under 26 U.S. Code § 911. Other income inputs are the amounts left after the exclusion. Section 911(f) adds this amount back to set the tax rates on that income: line 2c of the Foreign Earned Income Tax Worksheet (Form 2555 lines 45 and 50, less deductions disallowed because they relate to the excluded income). The net investment income tax adds back only the section 911(a)(1) part; see niit_magi_section_911_addition." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911", diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py index bf52b431407..8ecbfe48793 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py @@ -9,13 +9,16 @@ class niit_magi(Variable): documentation = ( "Form 8960 line 13 modified adjusted gross income: adjusted gross " "income plus the MAGI change that comes with Schedule K-1 (Form 1041) " - "box 14 code H amounts. A dependent's amounts stay on the dependent's " - "own return. The foreign earned income exclusion add-back and the " - "CFC and PFIC adjustments of Treas. Reg. 1.1411-10(e) are not modeled." + "box 14 code H amounts, plus the foreign earned income excluded under " + "26 U.S.C. 911(a)(1) net of the deductions disallowed for it (26 U.S.C. " + "1411(d); see niit_magi_section_911_addition). A dependent's amounts " + "stay on the dependent's own return. The CFC and PFIC adjustments of " + "Treas. Reg. 1.1411-10(e) are not modeled." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/1411#d", + "https://www.law.cornell.edu/cfr/text/26/1.1411-2", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=11", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=19", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=20", @@ -27,4 +30,14 @@ def formula(tax_unit, period, parameters): estate_magi_adjustment = tax_unit.sum( not_dependent * person("estate_income_niit_magi_adjustment", period) ) - return tax_unit("adjusted_gross_income", period) + estate_magi_adjustment + # Section 1411(d) adds "the excess (if any)" of the section + # 911(a)(1) exclusion over the amounts section 911(d)(6) disallows + # for it, so the addition is never negative. + section_911_addition = max_( + 0, tax_unit("niit_magi_section_911_addition", period) + ) + return ( + tax_unit("adjusted_gross_income", period) + + estate_magi_adjustment + + section_911_addition + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py new file mode 100644 index 00000000000..f8eb5ec2d63 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class niit_magi_section_911_addition(Variable): + value_type = float + entity = TaxUnit + label = "Section 911 addition to modified adjusted gross income for the net investment income tax" + unit = USD + documentation = ( + "The amount 26 U.S.C. 1411(d) adds to adjusted gross income: the " + "foreign earned income excluded under section 911(a)(1), less the " + "deductions and exclusions disallowed under section 911(d)(6) for it. " + "On the Form 8960 Line 13 MAGI Worksheet this is line 2c: Form 2555 " + "line 42 less the Form 2555 line 44 deductions allocable to it. " + "Section 1411(d) does not add back the housing exclusion of section " + "911(a)(2) (Form 2555 line 36) or the housing deduction (line 50). " + "The default is foreign_earned_income_exclusion, which holds the " + "housing amounts as well (Form 2555 lines 45 and 50), so for a filer " + "with a housing exclusion or deduction the default overstates MAGI by " + "the net housing amount. For such a filer, enter the worksheet amount " + "directly." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/1411#d", + "https://www.law.cornell.edu/uscode/text/26/911#a", + "https://www.law.cornell.edu/cfr/text/26/1.1411-2", + "https://www.irs.gov/pub/irs-prior/i8960--2025.pdf#page=22", + "https://www.irs.gov/pub/irs-prior/i8960--2025.pdf#page=23", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ) + + def formula(tax_unit, period, parameters): + return max_(0, tax_unit("foreign_earned_income_exclusion", period)) From 43bb42c0dbd27e84e450500e6bb3ba9e7f607ebf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:12:21 -0400 Subject: [PATCH 2/4] Describe the NIIT section 911 default exactly; test line 2b and QSS Review findings on #9808. foreign_earned_income_exclusion is Foreign Earned Income Tax Worksheet line 2c: Form 2555 lines 45 and 50 less the itemized deductions and exclusions disallowed for excluded income (line 2b). Section 1411(d)(2) subtracts only deductions taken into account in computing AGI, so the default addition is exact only with no housing amounts and nothing on line 2b: housing makes it too high, line 2b itemized deductions too low. The documentation said it could only overstate. Also: a YAML case restoring line 2b itemized deductions through an entered addition; surviving spouse ($250,000) in the property tests; tolerance scaled by the compared amounts only; the 2025 Form 8960 worksheet page in niit_magi's references. Co-Authored-By: Claude Opus 5.5 --- .../gov/irs/tax/federal_income/niit_magi.yaml | 27 +++++++++++++++++++ .../tests/test_niit_section_911_magi.py | 8 +++--- .../gov/irs/tax/federal_income/niit_magi.py | 1 + .../niit_magi_section_911_addition.py | 16 +++++++---- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml index f325f531755..353abce0001 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml @@ -161,3 +161,30 @@ output: # Section 1411(d) adds "the excess (if any)", never a negative amount. niit_magi: 150_000 + +- name: An entered worksheet amount restores itemized deductions netted out of the exclusion + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + employment_income: 150_000 + qualified_dividend_income: 50_000 + tax_units: + tax_unit: + members: [person1] + # Foreign Earned Income Tax Worksheet line 2c: a 100,000 foreign + # earned income exclusion (Form 2555 lines 42 and 45, no housing) + # less 5,000 of itemized deductions disallowed because they relate + # to it (line 2b). Section 1411(d)(2) subtracts only deductions + # taken into account in computing AGI, so the addition is 100,000. + foreign_earned_income_exclusion: 95_000 + niit_magi_section_911_addition: 100_000 + households: + household: + members: [person1] + state_code: TX + output: + adjusted_gross_income: 200_000 + niit_magi: 300_000 diff --git a/policyengine_us/tests/test_niit_section_911_magi.py b/policyengine_us/tests/test_niit_section_911_magi.py index b32c853b503..3107ea13190 100644 --- a/policyengine_us/tests/test_niit_section_911_magi.py +++ b/policyengine_us/tests/test_niit_section_911_magi.py @@ -24,8 +24,9 @@ addition is the exclusion, or the entered amount, floored at zero. 3. MAGI and the tax never fall as the excluded amount rises. 4. The tax is 3.8% of the lesser of net investment income and the excess of - MAGI over the threshold of section 1411(b) ($250,000 joint, $125,000 - separate, $200,000 otherwise, not indexed), computed here independently. + MAGI over the threshold of section 1411(b) ($250,000 joint or surviving + spouse, $125,000 separate, $200,000 otherwise, not indexed), computed + here independently. Households live in Texas. """ @@ -47,6 +48,7 @@ "HEAD_OF_HOUSEHOLD": 200_000, "JOINT": 250_000, "SEPARATE": 125_000, + "SURVIVING_SPOUSE": 250_000, } STATUSES = list(THRESHOLD) YEARS = [2018, 2022, 2025, 2026] @@ -254,7 +256,7 @@ def assert_invariants(households, year): # 2. MAGI is AGI plus the positive code H change plus the addition. code_h = np.array([max(0, h["code_h"]) for h in households]) magi = agi + code_h + addition - slack = tolerance(magi, more_excluded["niit_magi"]) + slack = tolerance(magi, law["niit_magi"]) assert (np.abs(law["niit_magi"] - magi) <= slack).all() assert (np.abs(law["niit_magi"] - before["niit_magi"] - addition) <= slack).all() entered = np.array([h["entered_addition"] is not None for h in households]) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py index 8ecbfe48793..57c8dce26f0 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi.py @@ -22,6 +22,7 @@ class niit_magi(Variable): "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=11", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=19", "https://www.irs.gov/pub/irs-prior/i8960--2024.pdf#page=20", + "https://www.irs.gov/pub/irs-prior/i8960--2025.pdf#page=23", ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py index f8eb5ec2d63..32ac16d142a 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py @@ -14,11 +14,16 @@ class niit_magi_section_911_addition(Variable): "line 42 less the Form 2555 line 44 deductions allocable to it. " "Section 1411(d) does not add back the housing exclusion of section " "911(a)(2) (Form 2555 line 36) or the housing deduction (line 50). " - "The default is foreign_earned_income_exclusion, which holds the " - "housing amounts as well (Form 2555 lines 45 and 50), so for a filer " - "with a housing exclusion or deduction the default overstates MAGI by " - "the net housing amount. For such a filer, enter the worksheet amount " - "directly." + "The default is foreign_earned_income_exclusion, line 2c of the Form " + "1040 Foreign Earned Income Tax Worksheet: Form 2555 lines 45 and 50, " + "less the itemized deductions and exclusions disallowed because they " + "relate to the excluded income (worksheet line 2b). The default " + "equals the section 1411(d) amount only for a filer with no housing " + "exclusion, no housing deduction and nothing on worksheet line 2b. " + "Housing amounts make it too high. Itemized deductions on line 2b " + "make it too low, since section 1411(d)(2) subtracts only deductions " + "taken into account in computing adjusted gross income. For any other " + "filer, enter the Form 8960 worksheet amount directly." ) definition_period = YEAR reference = ( @@ -28,6 +33,7 @@ class niit_magi_section_911_addition(Variable): "https://www.irs.gov/pub/irs-prior/i8960--2025.pdf#page=22", "https://www.irs.gov/pub/irs-prior/i8960--2025.pdf#page=23", "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", ) def formula(tax_unit, period, parameters): From a34c6d4bc6e5e82cc500009575112a139a01b545 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:13:14 -0400 Subject: [PATCH 3/4] State both reasons to enter the NIIT section 911 addition directly Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-niit-magi-section-911-addback.fixed.md | 2 +- policyengine_us/tests/test_niit_section_911_magi.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/changelog.d/fix-niit-magi-section-911-addback.fixed.md b/changelog.d/fix-niit-magi-section-911-addback.fixed.md index e02cc011b05..2d88afae01d 100644 --- a/changelog.d/fix-niit-magi-section-911-addback.fixed.md +++ b/changelog.d/fix-niit-magi-section-911-addback.fixed.md @@ -1 +1 @@ -Add the foreign earned income excluded under 26 U.S.C. 911(a)(1) to modified adjusted gross income for the net investment income tax, as 26 U.S.C. 1411(d) requires, through a new niit_magi_section_911_addition variable that a filer whose exclusion includes housing amounts can enter directly. +Add the foreign earned income excluded under 26 U.S.C. 911(a)(1) to modified adjusted gross income for the net investment income tax, as 26 U.S.C. 1411(d) requires, through a new niit_magi_section_911_addition variable. It defaults to foreign_earned_income_exclusion; a filer with housing amounts or itemized deductions disallowed under section 911(d)(6) can enter the Form 8960 worksheet amount directly. diff --git a/policyengine_us/tests/test_niit_section_911_magi.py b/policyengine_us/tests/test_niit_section_911_magi.py index 3107ea13190..a7b57b9ed65 100644 --- a/policyengine_us/tests/test_niit_section_911_magi.py +++ b/policyengine_us/tests/test_niit_section_911_magi.py @@ -9,7 +9,8 @@ `niit_magi_section_911_addition` is that excess. It defaults to `foreign_earned_income_exclusion` and can be entered directly, for a filer -whose exclusion includes housing amounts that section 1411(d) leaves out. +with housing amounts (which section 1411(d) leaves out) or with itemized +deductions netted out of the exclusion (which it does not subtract). Properties that hold for every household: From f33e44297d5c4835c9533581bc7518aacb1101ca Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 15:16:39 -0400 Subject: [PATCH 4/4] State the exact-default condition as sufficient, not necessary Line 2b exclusions disallowed for the 911(a)(1) amount are also subtracted under section 1411(d)(2), so an empty line 2b is enough for the default to be exact but not required. Spell out the line 44 premise in the line 2b YAML case. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/irs/tax/federal_income/niit_magi.yaml | 9 +++++---- .../tax/federal_income/niit_magi_section_911_addition.py | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml index 353abce0001..c34521f70aa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/niit_magi.yaml @@ -175,10 +175,11 @@ tax_unit: members: [person1] # Foreign Earned Income Tax Worksheet line 2c: a 100,000 foreign - # earned income exclusion (Form 2555 lines 42 and 45, no housing) - # less 5,000 of itemized deductions disallowed because they relate - # to it (line 2b). Section 1411(d)(2) subtracts only deductions - # taken into account in computing AGI, so the addition is 100,000. + # earned income exclusion (Form 2555 lines 42 and 45; no housing + # amounts and nothing on line 44) less 5,000 of itemized deductions + # disallowed because they relate to it (line 2b). Section 1411(d)(2) + # subtracts only deductions taken into account in computing AGI, so + # the addition is 100,000. foreign_earned_income_exclusion: 95_000 niit_magi_section_911_addition: 100_000 households: diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py index 32ac16d142a..1e66576ff87 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/niit_magi_section_911_addition.py @@ -17,9 +17,9 @@ class niit_magi_section_911_addition(Variable): "The default is foreign_earned_income_exclusion, line 2c of the Form " "1040 Foreign Earned Income Tax Worksheet: Form 2555 lines 45 and 50, " "less the itemized deductions and exclusions disallowed because they " - "relate to the excluded income (worksheet line 2b). The default " - "equals the section 1411(d) amount only for a filer with no housing " - "exclusion, no housing deduction and nothing on worksheet line 2b. " + "relate to the excluded income (worksheet line 2b). For a filer with " + "no housing exclusion, no housing deduction and nothing on worksheet " + "line 2b, the default equals the section 1411(d) amount. " "Housing amounts make it too high. Itemized deductions on line 2b " "make it too low, since section 1411(d)(2) subtracts only deductions " "taken into account in computing adjusted gross income. For any other "