diff --git a/changelog.d/section-911-filing-gross-exclusion.fixed.md b/changelog.d/section-911-filing-gross-exclusion.fixed.md new file mode 100644 index 00000000000..2185bf0f683 --- /dev/null +++ b/changelog.d/section-911-filing-gross-exclusion.fixed.md @@ -0,0 +1 @@ +Count Form 2555 line 43, the gross section 911 exclusion, toward the federal filing threshold under 26 U.S.C. 6012(c), instead of the Foreign Earned Income Tax Worksheet line 2c amount, and share one Form 2555 leaf input check across the formulas that choose between leaf and legacy inputs. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml new file mode 100644 index 00000000000..839bb09c946 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit_is_required_to_file.yaml @@ -0,0 +1,120 @@ +# 26 U.S.C. 6012(c): for the filing requirement, gross income is computed +# without regard to the section 911 exclusion. With Form 2555 leaf inputs, +# that is Form 2555 line 43 (the line 36 housing exclusion plus the line 42 +# foreign earned income exclusion), not the Foreign Earned Income Tax +# Worksheet line 2c stacking amount. Line 2c is net of the line 44 deductions +# and worksheet line 2b, and it includes the line 50 housing deduction, which +# is a deduction rather than an exclusion. The 2025 single threshold is the +# $15,750 standard deduction (Form 1040 instructions, Chart A). + +- name: Housing exclusion offset by allocable deductions still counts toward the threshold + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 10_000 + foreign_housing_exclusion: 10_000 + foreign_earned_income_exclusion_allocable_deductions: 10_000 + output: + # Line 2c is max(0, 10,000 - 10,000) = 0, but line 43 is 10,000. + foreign_earned_income_exclusion: 0 + foreign_earned_income_exclusion_gross: 10_000 + # $20,000 of gross income, over $15,750. + tax_unit_is_required_to_file: true + +- name: Worksheet line 2b disallowed deductions do not reduce filing gross income + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 10_000 + foreign_earned_income_exclusion_amount: 10_000 + foreign_earned_income_exclusion_disallowed_deductions: 10_000 + output: + foreign_earned_income_exclusion: 0 + # $20,000 of gross income, over $15,750. + tax_unit_is_required_to_file: true + +- name: The line 50 housing deduction is not an excluded amount + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 10_000 + foreign_earned_income_exclusion_amount: 5_000 + foreign_housing_deduction: 5_000 + output: + # Line 2c adds the deduction (10,000); line 43 does not (5,000). + foreign_earned_income_exclusion: 10_000 + foreign_earned_income_exclusion_gross: 5_000 + # $15,000 of gross income, under $15,750. + tax_unit_is_required_to_file: false + +- name: Supplied leaves replace an explicit zero legacy amount + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 10_000 + foreign_earned_income_exclusion: 0 + foreign_housing_exclusion: 10_000 + output: + # $20,000 of gross income, over $15,750. + tax_unit_is_required_to_file: true + +- name: Without leaf inputs the legacy amount is the fallback + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 10_000 + foreign_earned_income_exclusion: 10_000 + output: + # $20,000 of gross income, over $15,750. + tax_unit_is_required_to_file: true + +- name: A negative legacy amount does not lower filing gross income + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + employment_income: 18_000 + foreign_earned_income_exclusion: -5_000 + output: + # An exclusion cannot be negative (worksheet line 2c is floored at zero), + # so gross income stays $18,000, over $15,750. + tax_unit_is_required_to_file: true + +- name: Joint filers add line 43 from both spouses' forms + period: 2025 + input: + people: + head: + age: 40 + employment_income: 20_000 + spouse: + age: 40 + tax_units: + tax_unit: + members: [head, spouse] + foreign_housing_exclusion: 12_000 + foreign_earned_income_exclusion_allocable_deductions: 12_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + foreign_earned_income_exclusion: 0 + # $32,000 of gross income, over the $31,500 joint threshold. + tax_unit_is_required_to_file: true + +- name: Excluded amounts from leaves do not count as unearned income + period: 2025 + input: + filing_status: SINGLE + age_head: 40 + foreign_housing_exclusion: 10_000 + foreign_earned_income_exclusion_allocable_deductions: 10_000 + output: + # $10,000 of gross income, under $15,750, and none of it is unearned. + tax_unit_is_required_to_file: false diff --git a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py index 2510da6ef04..5b9f10b9a28 100644 --- a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py +++ b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py @@ -1,9 +1,17 @@ """Leaf-input identities, worksheet semantics, and legacy-input precedence.""" +from pathlib import Path + import numpy as np import pytest +import policyengine_us +from policyengine_core.periods import period from policyengine_us import Simulation +from policyengine_us.tools.section_911 import ( + SECTION_911_LEAF_INPUTS, + elects_section_911_exclusion, +) hypothesis = pytest.importorskip("hypothesis") st = pytest.importorskip("hypothesis.strategies") @@ -18,17 +26,16 @@ ) -def simulation_for_tax_units(inputs): +def simulation_for_tax_units(inputs, person_inputs=None): """Independent single-person tax units, with uniform input presence.""" people, tax_units, households = {}, {}, {} groups = {"marital_units": {}, "spm_units": {}, "families": {}} + if person_inputs is None: + person_inputs = {"irs_gross_income": {YEAR: 50_000}} for index, tax_unit_inputs in enumerate(inputs): person = f"person_{index}" members = [person] - people[person] = { - "age": {YEAR: 40}, - "irs_gross_income": {YEAR: 50_000}, - } + people[person] = {"age": {YEAR: 40}, **person_inputs} tax_units[f"tax_unit_{index}"] = { "members": members, **tax_unit_inputs, @@ -92,6 +99,39 @@ def test_gross_sum_and_nonnegative_massachusetts_addback(rows): ) +@hypothesis.settings(max_examples=5, deadline=None, derandomize=True) +@hypothesis.example([(5_000, 0, 0, 5_000, 0), (0, 10_000, 10_000, 0, 10_000)]) +@hypothesis.given( + st.lists( + st.tuples( + st.integers(-20_000, 20_000), + st.integers(-20_000, 20_000), + st.integers(0, 20_000), + st.integers(0, 20_000), + st.integers(0, 20_000), + ), + min_size=1, + max_size=8, + ) +) +def test_filing_gross_income_adds_line_43_and_ignores_deductions(rows): + # 26 U.S.C. 6012(c): only the line 36 and line 42 exclusions count, never + # less than zero. Lines 44 and 50 and worksheet line 2b never matter, and + # the excluded amount is not unearned income. + wages = 10_000 + simulation = simulation_for_tax_units( + [{name: {YEAR: value} for name, value in zip(LEAVES, row)} for row in rows], + person_inputs={"employment_income": {YEAR: wages}}, + ) + amounts = np.asarray(rows, dtype=float) + line_43 = np.maximum(0, amounts[:, 0] + amounts[:, 1]) + threshold = simulation.calculate("standard_deduction", YEAR) + np.testing.assert_array_equal( + simulation.calculate("tax_unit_is_required_to_file", YEAR), + wages + line_43 > threshold, + ) + + @pytest.mark.parametrize( "first", [ @@ -247,3 +287,70 @@ def test_form_2555_filers_skip_worksheet_b_even_when_stacking_is_zero(amount, ex np.testing.assert_array_equal( simulation.calculate("ctc_credit_limit_worksheet_b_applies", YEAR), [False] ) + + +# Formulas that choose between the Form 2555 leaves and the legacy aggregates, +# with the year each is checked in. On the leaf path in the test below every +# amount is zero and the filer is under the threshold. +LEAF_PATH_OUTPUTS = { + ("foreign_earned_income_exclusion", YEAR + 1): 0, + ("section_911_excluded_income", YEAR): 0, + ("ma_foreign_earned_income_exclusion_addback", YEAR): 0, + ("tax_unit_is_required_to_file", YEAR): False, +} + + +def test_worksheet_oracle_names_every_leaf(): + # The property tests above build rows positionally from LEAVES, so a new + # leaf needs its own column and worksheet arithmetic there. + assert set(LEAVES) == set(SECTION_911_LEAF_INPUTS) + + +@pytest.mark.parametrize("leaf", SECTION_911_LEAF_INPUTS) +def test_each_leaf_moves_every_consumer_off_the_legacy_fallback(leaf): + # The legacy amount entered for YEAR overrides the federal stacking amount + # there and carries, uprated, into YEAR + 1. An explicit zero for any one + # leaf must select the leaf calculation in every formula that chooses + # between the two, where every amount is zero and filing gross income is + # the $10,000 of wages, under the $15,750 threshold. A formula that + # ignores the leaf falls back to the legacy amount: $10,000 in YEAR (and + # $20,000 of filing gross income), or its carry in YEAR + 1. Downstream + # formulas are checked in YEAR because their fallback there is the entered + # amount, not a federal formula that already switched to the leaves. + simulation = simulation_for_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: 10_000}, leaf: {YEAR: 0}}], + person_inputs={"employment_income": {YEAR: 10_000}}, + ) + outputs = { + (variable, year): simulation.calculate(variable, year).tolist() + for variable, year in LEAF_PATH_OUTPUTS + } + assert outputs == {key: [value] for key, value in LEAF_PATH_OUTPUTS.items()} + + +@pytest.mark.parametrize("leaf", SECTION_911_LEAF_INPUTS) +def test_each_positive_leaf_identifies_a_form_2555_filer(leaf): + simulation = simulation_for_tax_units([{leaf: {YEAR: 1_000}}]) + tax_unit = simulation.populations["tax_unit"] + assert elects_section_911_exclusion(tax_unit, period(YEAR)).tolist() == [True] + + +def test_only_the_shared_helper_checks_leaf_input_provenance(): + # Formulas must call has_section_911_leaf_inputs rather than repeat the + # leaf list next to Core's input-provenance helper. + package = Path(policyengine_us.__file__).parent + helper = package / "tools" / "section_911.py" + paths = [ + path + for folder in ("variables", "tools", "reforms") + for path in sorted((package / folder).rglob("*.py")) + if path != helper + ] + offenders = [] + for path in paths: + text = path.read_text() + if "_get_exportable_input_periods" in text and any( + leaf in text for leaf in SECTION_911_LEAF_INPUTS + ): + offenders.append(str(path.relative_to(package))) + assert offenders == [] diff --git a/policyengine_us/tools/section_911.py b/policyengine_us/tools/section_911.py index 1abe578553d..9a6b1f26f9a 100644 --- a/policyengine_us/tools/section_911.py +++ b/policyengine_us/tools/section_911.py @@ -13,6 +13,49 @@ ) +def has_section_911_leaf_inputs(tax_unit, period): + """Whether any Form 2555 leaf input applies to the period. + + A leaf supplied for the period or carried from an earlier one counts. + Core's input export helper tracks supplied values by year and branch, so + cached default zeros do not count and explicitly supplied zeros do + (available since core 3.32.8). Every formula that switches between the + leaves and the legacy aggregates uses this test. + """ + return any( + input_period.start <= period.start + for variable in SECTION_911_LEAF_INPUTS + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ) + + +def section_911_gross_exclusion(tax_unit, period): + """Amounts excluded from gross income under section 911(a), floored at zero. + + With leaf inputs, this is Form 2555 line 43 (the line 36 housing + exclusion plus the line 42 foreign earned income exclusion), before the + line 44 allocable deductions and worksheet line 2b, and without the line + 50 housing deduction, which is a deduction rather than an exclusion. + Without leaf inputs, the legacy foreign_earned_income_exclusion amount + (worksheet line 2c) is the fallback. It equals line 43 when lines 44 and + 50 and worksheet line 2b are zero, or when line 50 equals line 44 plus + line 2b. An exclusion cannot be negative, so neither amount can reduce + gross income. + + References: + https://www.law.cornell.edu/uscode/text/26/911#a + https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3 + https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37 + """ + if has_section_911_leaf_inputs(tax_unit, period): + excluded = tax_unit("foreign_earned_income_exclusion_gross", period) + else: + excluded = tax_unit("foreign_earned_income_exclusion", period) + return np.maximum(0, excluded) + + def elects_section_911_exclusion(tax_unit, period): """Detect a section 911 election from aggregates or Form 2555 leaves. @@ -23,8 +66,24 @@ def elects_section_911_exclusion(tax_unit, period): Schedule 8812 (Line 13 and Part II-A) and Publication 596 (Rule 5) bar Form 2555 filers from Worksheet B, refundable CTC, and EITC. + A filer who claims only the section 911(c)(4) housing deduction (Form + 2555 line 50) is also barred. That case rests on the IRS instructions, + not on the statute. Section 24(d)(3) applies to a taxpayer who "elects + to exclude any amount from gross income under section 911", and section + 911(c)(4)(A) treats the housing amount as "a deduction allowable in + computing adjusted gross income", not an exclusion. The Schedule 8812 + instructions bar every Form 2555 filer: Part II-A says "If you file Form + 2555, you cannot claim the additional child tax credit", and Credit + Limit Worksheet B applies only if "You are not filing Form 2555". + Publication 596 Rule 5 denies the EITC to anyone who files Form 2555 "to + deduct or exclude a foreign housing amount"; section 32(c)(1)(C) excludes + an individual who "claims the benefits of section 911". + References: https://www.law.cornell.edu/uscode/text/26/911#a + https://www.law.cornell.edu/uscode/text/26/911#c_4 + https://www.law.cornell.edu/uscode/text/26/24#d_3 + https://www.law.cornell.edu/uscode/text/26/32#c_1_C https://www.irs.gov/instructions/i1040s8 https://www.irs.gov/publications/p596 """ 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 08310ede9fd..6769e38f1c0 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 @@ -2,6 +2,7 @@ from policyengine_core.parameters import get_parameter from policyengine_core.simulations.simulation import _uprating_index_value from policyengine_us.tools.default_uprating import DEFAULT_DOLLAR_INPUT_UPRATING +from policyengine_us.tools.section_911 import has_section_911_leaf_inputs class foreign_earned_income_exclusion(Variable): @@ -17,20 +18,7 @@ class foreign_earned_income_exclusion(Variable): ] def formula(tax_unit, period, parameters): - leaf_inputs = [ - "foreign_earned_income_exclusion_amount", - "foreign_housing_exclusion", - "foreign_earned_income_exclusion_allocable_deductions", - "foreign_housing_deduction", - "foreign_earned_income_exclusion_disallowed_deductions", - ] - if not any( - input_period.start <= period.start - for variable in leaf_inputs - for input_period in tax_unit.simulation._get_exportable_input_periods( - variable, include_computed_variables=False - ) - ): + if not has_section_911_leaf_inputs(tax_unit, period): # A formula cannot also declare Core uprating. Preserve the # old dollar input's per-capita default uprating here instead, # including signed inputs and Core's held-flat index boundaries. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py index 4f53757cb87..814702d743d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py @@ -1,5 +1,5 @@ from policyengine_us.model_api import * -from policyengine_us.tools.section_911 import SECTION_911_LEAF_INPUTS +from policyengine_us.tools.section_911 import has_section_911_leaf_inputs class section_911_excluded_income(Variable): @@ -34,14 +34,7 @@ class section_911_excluded_income(Variable): ) def formula(tax_unit, period, parameters): - has_leaf_inputs = any( - input_period.start <= period.start - for variable in SECTION_911_LEAF_INPUTS - for input_period in tax_unit.simulation._get_exportable_input_periods( - variable, include_computed_variables=False - ) - ) - if not has_leaf_inputs: + if not has_section_911_leaf_inputs(tax_unit, period): return tax_unit("foreign_earned_income_exclusion", period) gross = tax_unit("foreign_earned_income_exclusion_gross", period) diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py index 5c38c142427..620cf3409ff 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_required_to_file.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import section_911_gross_exclusion class tax_unit_is_required_to_file(Variable): @@ -8,19 +9,29 @@ class tax_unit_is_required_to_file(Variable): documentation = """ Whether this tax unit is legally required to file a federal income tax return under IRC § 6012, based on gross income thresholds and filing - status. + status. Under § 6012(c), gross income includes amounts excluded under + § 911: Form 2555 line 43 when Form 2555 leaf inputs are supplied, + otherwise the legacy foreign_earned_income_exclusion amount. """ definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/6012", # Gross income counts amounts excluded under section 911. "https://www.law.cornell.edu/uscode/text/26/6012#c", + # Chart A: gross income includes income from sources outside the + # United States "even if you can exclude part or all of it". + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=9", + # Form 2555 line 43: the line 36 and line 42 exclusions. + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", ) def formula(tax_unit, period, parameters): # Section 6012(c): for the filing requirement, gross income is - # computed without regard to the section 911 exclusion. - section_911_exclusion = tax_unit("foreign_earned_income_exclusion", period) + # computed without regard to the section 911 exclusion. That is the + # gross exclusion on Form 2555 line 43, not the worksheet line 2c + # stacking amount, which is net of line 44 and worksheet line 2b and + # includes the line 50 housing deduction. + section_911_exclusion = section_911_gross_exclusion(tax_unit, period) gross_income = add(tax_unit, period, ["irs_gross_income"]) + ( section_911_exclusion ) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py index 9eca8685b1e..4700cbce014 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import section_911_gross_exclusion class ma_foreign_earned_income_exclusion_addback(Variable): @@ -42,22 +43,4 @@ class ma_foreign_earned_income_exclusion_addback(Variable): defined_for = StateCode.MA def formula(tax_unit, period, parameters): - leaf_inputs = [ - "foreign_earned_income_exclusion_amount", - "foreign_housing_exclusion", - "foreign_earned_income_exclusion_allocable_deductions", - "foreign_housing_deduction", - "foreign_earned_income_exclusion_disallowed_deductions", - ] - # Core's input export helper tracks supplied values by year and - # branch. Cached default zeros must not replace the legacy fallback; - # explicitly supplied zeros must. Available since core 3.32.8. - if any( - input_period.start <= period.start - for variable in leaf_inputs - for input_period in tax_unit.simulation._get_exportable_input_periods( - variable, include_computed_variables=False - ) - ): - return max_(0, tax_unit("foreign_earned_income_exclusion_gross", period)) - return max_(0, tax_unit("foreign_earned_income_exclusion", period)) + return section_911_gross_exclusion(tax_unit, period)