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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/section-911-filing-gross-exclusion.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
117 changes: 112 additions & 5 deletions policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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",
[
Expand Down Expand Up @@ -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 == []
59 changes: 59 additions & 0 deletions policyengine_us/tools/section_911.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down
Loading
Loading