From a4c3ad0033be5b0d55d3a021fc49ae13713c762d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:22:17 -0400 Subject: [PATCH 1/8] Figure student-loan MAGI as 26 U.S.C. 221(b)(2)(C) says Section 221(b)(2)(C) defines modified AGI as adjusted gross income determined "(i) without regard to this section and sections 85(c), 911, 931, and 933, and (ii) after application of sections 86, 135, 137, 219, and 469". The model got three parts wrong: - Section 931 (specified_possession_income) was subtracted like any other above-the-line deduction. It is now in excluded_alds with section 933. - Section 911: income inputs are net of foreign_earned_income_exclusion, and nothing added it back. The formula now adds it, split between spouses like the tax unit's deductions. - Unemployment compensation was dropped entirely (excluded_gross_income_ sources), a misreading of "without regard to section 85(c)": only the 2020 exclusion is disregarded, so all of it counts. The formula reads the untaxed amount, which also avoids the cycle through AGI that the old exclusion side-stepped. The parameter is removed. excluded_alds also follows the amendment history: section 222 until 2020 (Pub. L. 116-260) and section 199 from 2005 through 2017 (Pub. L. 108-357, Pub. L. 115-97). Co-Authored-By: Claude Opus 5.5 --- .../magi/excluded_alds.yaml | 31 +++- .../magi/excluded_gross_income_sources.yaml | 13 -- .../tests/core/test_ald_determinism.py | 1 - .../student_loan_interest_ald.yaml | 96 +++++++++++- .../student_loan_interest_ald_magi.yaml | 138 +++++++++++++++++- .../student_loan_interest_ald_magi.py | 58 +++++--- 6 files changed, 294 insertions(+), 43 deletions(-) delete mode 100644 policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_gross_income_sources.yaml diff --git a/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_alds.yaml b/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_alds.yaml index a6bcaead89a..622d8e00f63 100644 --- a/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_alds.yaml +++ b/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_alds.yaml @@ -5,10 +5,37 @@ metadata: period: year label: Student loan interest deduction excluded above the line deductions reference: - - title: 26 U.S. Code § 221 - Interest on education loans (b)(2)(C) - href: https://www.law.cornell.edu/uscode/text/26/221 + # (i) "without regard to this section and sections 85(c), 911, 931, and + # 933". Sections 931 (specified_possession_income) and 933 + # (puerto_rico_income) are above-the-line deductions in this model; + # section 911 is added back in the formula. Section 222 was struck for + # taxable years beginning after 2020 (Pub. L. 116-260, div. EE, + # § 104(b)(2)(G)); section 199 was inserted for taxable years beginning + # after 2004 (Pub. L. 108-357, § 102) and struck for taxable years + # beginning after 2017 (Pub. L. 115-97, § 13305(b)(1)). + - title: 26 U.S. Code § 221(b)(2)(C) - Interest on education loans, modified adjusted gross income + href: https://www.law.cornell.edu/uscode/text/26/221#b_2_C + - title: IRS Publication 970 (2024), Worksheet 4-1, lines 3, 7 and 8 + href: https://www.irs.gov/pub/irs-prior/p970--2024.pdf#page=36 values: 2002-01-01: - student_loan_interest_ald + - capped_qualified_tuition_expenses_ald + - specified_possession_income + - puerto_rico_income + 2005-01-01: + - student_loan_interest_ald + - capped_qualified_tuition_expenses_ald + - domestic_production_ald + - specified_possession_income + - puerto_rico_income + 2018-01-01: + - student_loan_interest_ald + - capped_qualified_tuition_expenses_ald + - specified_possession_income + - puerto_rico_income + 2021-01-01: + - student_loan_interest_ald + - specified_possession_income - puerto_rico_income diff --git a/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_gross_income_sources.yaml b/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_gross_income_sources.yaml deleted file mode 100644 index 62cf9f56191..00000000000 --- a/policyengine_us/parameters/gov/irs/ald/student_loan_interest/magi/excluded_gross_income_sources.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: IRS excludes the following gross income sources from the modified adjusted gross income under the student loan interest deduction. - -metadata: - unit: list - period: year - label: Student loan interest deduction excluded gross income sources - reference: - - title: 26 U.S. Code § 221 - Interest on education loans (b)(2)(C) - href: https://www.law.cornell.edu/uscode/text/26/221 - -values: - 2002-01-01: - - taxable_unemployment_compensation diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index 3ef4ff22cca..08fab741c1f 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -55,7 +55,6 @@ def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( magi = SimpleNamespace( person_alds=PERSON_ALDS, excluded_alds=["student_loan_interest_ald", "puerto_rico_income"], - excluded_gross_income_sources=[], ) parameters = SimpleNamespace( gov=SimpleNamespace( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml index 5ce7857bc3c..69a1db0ecd2 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml @@ -83,7 +83,7 @@ output: student_loan_interest_ald: [0, 0] -- name: No deduction for separate filers, unemployment comp excluded +- name: No deduction for separate filers period: 2021 absolute_error_margin: 0.1 input: @@ -91,7 +91,7 @@ person1: student_loan_interest: 3_000 miscellaneous_income: 80_000 - taxable_unemployment_compensation: 10_000 + unemployment_compensation: 10_000 student_loan_interest_ald_eligible: false person2: student_loan_interest: 1_000 @@ -148,3 +148,95 @@ # (default MAGI below the phase-out start for joint filers). output: student_loan_interest_ald: [1_250, 1_250] + +# 2024 phase-out: $80,000 to $95,000 single, $165,000 to $195,000 joint. +- name: Section 931 possession income counts toward the phase-out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 85_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + specified_possession_income: 10_000 + output: + # MAGI 85,000: reduced by (85,000 - 80,000) / 15,000 = 1/3. + student_loan_interest_ald: 1_666.67 + +- name: Section 933 Puerto Rico income counts toward the phase-out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 85_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + puerto_rico_income: 10_000 + output: + student_loan_interest_ald: 1_666.67 + +- name: Section 911 excluded foreign earned income counts toward the phase-out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 75_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + foreign_earned_income_exclusion: 10_000 + output: + student_loan_interest_ald: 1_666.67 + +- name: Unemployment compensation counts toward the phase-out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 75_000 + unemployment_compensation: 10_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + output: + student_loan_interest_ald: 1_666.67 + +- name: Joint return with section 911 and 931 exclusions is in the phase-out + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 100_000 + student_loan_interest_ald_eligible: true + person2: + irs_employment_income: 80_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + foreign_earned_income_exclusion: 10_000 + specified_possession_income: 20_000 + output: + # AGI 160,000; MAGI 190,000: reduced by (190,000 - 165,000) / 30,000. + student_loan_interest_ald: [416.67, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml index 271f816df7b..f2745241135 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml @@ -38,14 +38,14 @@ output: student_loan_interest_ald_magi: [80_000, 79_000] -- name: Unemployment comp excluded +- name: Unemployment compensation counts in full period: 2021 absolute_error_margin: 1 input: people: person1: miscellaneous_income: 80_000 - taxable_unemployment_compensation: 10_000 + unemployment_compensation: 10_000 student_loan_interest_ald_eligible: true person2: self_employment_income: 80_000 @@ -55,7 +55,9 @@ members: [person1, person2] filing_status: SEPARATE output: - student_loan_interest_ald_magi: [80_000, 74_348] + # 26 U.S.C. 221(b)(2)(C)(i) disregards only the section 85(c) exclusion; + # unemployment compensation is otherwise in AGI: 80,000 + 10,000. + student_loan_interest_ald_magi: [90_000, 74_348] - name: One person with no income period: 2021 @@ -98,3 +100,133 @@ basic_income: 1_000 output: student_loan_interest_ald_magi: [79_000, 0] + +- name: Section 931 possession income is added back + period: 2024 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 85_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + specified_possession_income: 10_000 + output: + # AGI is 85,000 - 10,000 = 75,000; MAGI is figured without regard to + # section 931: 75,000 + 10,000. + adjusted_gross_income: 75_000 + student_loan_interest_ald_magi: 85_000 + +- name: Section 933 Puerto Rico income is added back + period: 2024 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 85_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + puerto_rico_income: 10_000 + output: + adjusted_gross_income: 75_000 + student_loan_interest_ald_magi: 85_000 + +- name: Section 911 foreign earned income exclusion is added back + period: 2024 + absolute_error_margin: 0.1 + input: + people: + person1: + # Income inputs are net of the exclusion. + irs_employment_income: 75_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + foreign_earned_income_exclusion: 10_000 + output: + adjusted_gross_income: 75_000 + student_loan_interest_ald_magi: 85_000 + +- name: Joint return splits the section 911 and 931 add-backs between spouses + period: 2024 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 100_000 + student_loan_interest_ald_eligible: true + person2: + irs_employment_income: 80_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + foreign_earned_income_exclusion: 10_000 + specified_possession_income: 20_000 + output: + # AGI 180,000 - 20,000 = 160,000. MAGI 160,000 + 20,000 + 10,000 = + # 190,000; each spouse carries half of the 10,000 exclusion. + adjusted_gross_income: 160_000 + student_loan_interest_ald_magi: [105_000, 85_000] + +- name: Section 85(c) unemployment exclusion is added back in 2020 + period: 2020 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 67_000 + unemployment_compensation: 10_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + output: + # Section 85(c) excludes all 10,000 (up to 10,200), so none is taxable, + # but MAGI is figured without regard to it: 67,000 + 10,000. + tax_unit_taxable_unemployment_compensation: 0 + student_loan_interest_ald_magi: 77_000 + +- name: Section 222 tuition and fees deduction is added back before 2021 + period: 2019 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 60_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + capped_qualified_tuition_expenses_ald: 4_000 + output: + adjusted_gross_income: 56_000 + student_loan_interest_ald_magi: 60_000 + +- name: Section 199 domestic production deduction is added back before 2018 + period: 2017 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 60_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + domestic_production_ald: 3_000 + output: + adjusted_gross_income: 57_000 + student_loan_interest_ald_magi: 60_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index d4d6df1142e..4e8b67c9667 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -7,47 +7,61 @@ class student_loan_interest_ald_magi(Variable): label = "Modified adjusted gross income for the student loan interest ALD" unit = USD definition_period = YEAR - documentation = "Above-the-line deduction for student loan interest" - reference = "https://www.law.cornell.edu/uscode/text/26/221#b_2_C" + documentation = ( + "Modified adjusted gross income of 26 U.S.C. 221(b)(2)(C): adjusted " + "gross income determined without regard to section 221 and sections " + "85(c), 911, 931 and 933 (and, in earlier years, sections 199 and " + "222), and after application of sections 86, 135, 137, 219 and 469. " + "Built from gross income sources rather than adjusted gross income, " + "which includes this deduction. On a joint return each spouse " + "carries half of the tax unit's deductions and exclusions." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/221#b_2_C", + "https://www.irs.gov/pub/irs-prior/p970--2024.pdf#page=36", + ) defined_for = "student_loan_interest_ald_eligible" def formula(person, period, parameters): p_irs = parameters(period).gov.irs + p = p_irs.ald.student_loan_interest.magi not_dependent = ~person("is_tax_unit_dependent", period) - gross_income_sources = p_irs.gross_income.sources - # Unemployment compensation is not considered in MAGI - gross_income = [ - income_source - for income_source in gross_income_sources - if income_source - not in p_irs.ald.student_loan_interest.magi.excluded_gross_income_sources - ] + gross_income_sources = list(p_irs.gross_income.sources) + if "taxable_unemployment_compensation" in gross_income_sources: + # MAGI is determined without regard to section 85(c), so all + # unemployment compensation counts, including any amount 85(c) + # excluded for 2020. Reading the untaxed amount also avoids a + # cycle: taxable unemployment compensation depends on AGI, which + # includes this deduction. + gross_income_sources.remove("taxable_unemployment_compensation") + gross_income_sources.append("unemployment_compensation") total_gross_income = 0 - for source in gross_income: + for source in gross_income_sources: total_gross_income += not_dependent * max_(0, add(person, period, [source])) - # Modified gross income is calculated with certain deductions excluded - person_alds = p_irs.ald.student_loan_interest.magi.person_alds - person_ald_vars = [f"{ald}_person" for ald in person_alds] + person_ald_vars = [f"{ald}_person" for ald in p.person_alds] ald_sum_person = add(person, period, person_ald_vars) - all_alds = p_irs.ald.deductions + # Deductions for sections 221, 931 and 933 (and 199 and 222 in + # earlier years) are listed in excluded_alds and not subtracted. other_alds = sorted( - set(all_alds) - - set(person_alds) - - set(p_irs.ald.student_loan_interest.magi.excluded_alds) + set(p_irs.ald.deductions) - set(p.person_alds) - set(p.excluded_alds) ) ald_sum_taxunit = add(person.tax_unit, period, other_alds) + # Income inputs are net of the section 911 exclusion; add it back. + foreign_earned_income_exclusion = person.tax_unit( + "foreign_earned_income_exclusion", period + ) filing_status = person.tax_unit("filing_status", period) joint = filing_status == filing_status.possible_values.JOINT frac = where(joint, 0.5, 1.0) - ald_sum_taxunit_shared = not_dependent * ald_sum_taxunit * frac - + taxunit_adjustment_shared = ( + not_dependent * (foreign_earned_income_exclusion - ald_sum_taxunit) * frac + ) modified_adjusted_gross_income = ( - total_gross_income - ald_sum_person - ald_sum_taxunit_shared + total_gross_income - ald_sum_person + taxunit_adjustment_shared ) if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) # split basic income evenly between head and spouse basic_income_shared = not_dependent * basic_income * frac modified_adjusted_gross_income += basic_income_shared - # Combine the income for units filing jointly return modified_adjusted_gross_income From 93ae376d28122f393c390db0ce6e1b0d6c498e66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:22:32 -0400 Subject: [PATCH 2/8] Phase out on AGI plus income excluded under sections 911, 931 and 933 Ten provisions phase out on "adjusted gross income increased by any amount excluded from gross income under section 911, 931, or 933": 24(b)(1) (CTC, and the 2021 ARPA phase-out through Schedule 8812 line 3), 25A(d)(2) (education credits), 25B(e) (saver's credit), 25E(b)(3) and 30D(f)(10)(C) (clean vehicle credits), 151(d)(5)(C) (senior deduction), 163(h)(4)(C) (car loan interest), 164(b)(7)(B) (SALT cap), 224(b)(2)(B) (tips) and 225(b)(2)(B) (overtime). Only the senior deduction added the exclusions back; the rest read plain AGI. agi_plus_section_911_931_933_exclusions computes the amount once (Schedule 1-A line 3), and every one of them reads it. Co-Authored-By: Claude Opus 5.5 --- .../new_clean_vehicle_credit_eligible.yaml | 13 +++++++ .../used_clean_vehicle_credit_eligible.yaml | 10 +++++ .../arpa/ctc_arpa_uncapped_phase_out.yaml | 10 +++++ .../credits/ctc/phase_out/ctc_phase_out.yaml | 12 ++++++ .../education_credit_phase_out.yaml | 9 +++++ .../lifetime_learning_credit_phase_out.yaml | 17 ++++++++ .../savers_credit_person.yaml | 19 +++++++++ .../salt_and_real_estate/salt_deduction.yaml | 17 ++++++++ ...i_plus_section_911_931_933_exclusions.yaml | 34 ++++++++++++++++ .../auto_loan_interest_deduction.yaml | 12 ++++++ .../overtime_income_deduction.yaml | 12 ++++++ .../tip_income/tip_income_deduction.yaml | 13 +++++++ .../new/new_clean_vehicle_credit_eligible.py | 6 ++- .../used_clean_vehicle_credit_eligible.py | 8 ++-- .../arpa/ctc_arpa_uncapped_phase_out.py | 6 ++- .../credits/ctc/phase_out/ctc_phase_out.py | 4 +- .../education/education_credit_phase_out.py | 8 ++-- .../lifetime_learning_credit_phase_out.py | 8 ++-- .../savers_credit_person.py | 4 +- ...agi_plus_section_911_931_933_exclusions.py | 39 +++++++++++++++++++ .../auto_loan_interest_deduction.py | 7 +++- .../deductions/itemizing/salt_cap.py | 8 ++-- .../overtime_income_deduction.py | 8 ++-- .../additional_senior_deduction_magi.py | 17 ++------ .../tip_income/tip_income_deduction.py | 8 ++-- 25 files changed, 270 insertions(+), 39 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/education/lifetime_learning_credit/lifetime_learning_credit_phase_out.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml index 22bb0f02292..29ca2fa0099 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml @@ -68,3 +68,16 @@ adjusted_gross_income: 150_000 output: new_clean_vehicle_credit_eligible: false + +- name: Income excluded under section 911 counts toward the income limit. + period: 2024 + input: + purchased_qualifying_new_clean_vehicle: true + new_clean_vehicle_battery_capacity: 4 + new_clean_vehicle_classification: VAN + new_clean_vehicle_msrp: 50_000 + adjusted_gross_income: 140_000 + foreign_earned_income_exclusion: 20_000 + output: + # 26 U.S.C. 30D(f)(10)(C): modified AGI 160,000 is over the 150,000 limit. + new_clean_vehicle_credit_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml index 37dd6276173..0d7f1d3d233 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml @@ -63,3 +63,13 @@ adjusted_gross_income: 75_000 output: used_clean_vehicle_credit_eligible: false + +- name: Income excluded under section 933 counts toward the income limit. + period: 2024 + input: + purchased_qualifying_used_clean_vehicle: true + adjusted_gross_income: 70_000 + puerto_rico_income: 10_000 + output: + # 26 U.S.C. 25E(b)(3): modified AGI 80,000 is over the 75,000 limit. + used_clean_vehicle_credit_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.yaml index d29453f064a..50a2cfd1341 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.yaml @@ -23,3 +23,13 @@ ctc_arpa_phase_out_threshold: 150_000 output: ctc_arpa_uncapped_phase_out: 500 # Rounded up to $10k excess above $150k ARPA threshold * 5%. + +- name: The ARPA phase-out also counts income excluded under section 911. + period: 2021 + input: + adjusted_gross_income: 150_000 + foreign_earned_income_exclusion: 9_100 + ctc_arpa_phase_out_threshold: 150_000 + output: + # Schedule 8812 (2021) line 3: modified AGI 159,100; 9,100 rounds up to 10,000. + ctc_arpa_uncapped_phase_out: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml index 79fb7c1bca4..d7a747e73f0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml @@ -13,3 +13,15 @@ ctc_phase_out_threshold: 200_000 # HOH. output: ctc_phase_out: 10_000 # $200k * 5%. Gets capped at maximum. + +- name: Income excluded under sections 911, 931 and 933 counts toward the phase-out. + period: 2024 + input: + adjusted_gross_income: 395_000 + foreign_earned_income_exclusion: 6_000 + specified_possession_income: 2_000 + puerto_rico_income: 2_000 + ctc_phase_out_threshold: 400_000 # Joint. + output: + # 26 U.S.C. 24(b)(1): modified AGI 405,000; 5 increments of $1,000 x $50. + ctc_phase_out: 250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/education/american_opportunity_credit/education_credit_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/education/american_opportunity_credit/education_credit_phase_out.yaml index 20369299295..569a9ebd2cb 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/education/american_opportunity_credit/education_credit_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/education/american_opportunity_credit/education_credit_phase_out.yaml @@ -26,3 +26,12 @@ tax_unit_is_joint: true output: education_credit_phase_out: 0.5 + +- name: Income excluded under sections 911, 931 and 933 counts toward the phase-out + period: 2024 + input: + adjusted_gross_income: 78_000 + puerto_rico_income: 7_000 + output: + # 26 U.S.C. 25A(d)(2): modified AGI 85,000; (85,000 - 80,000) / 10,000. + education_credit_phase_out: 0.5 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/education/lifetime_learning_credit/lifetime_learning_credit_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/education/lifetime_learning_credit/lifetime_learning_credit_phase_out.yaml new file mode 100644 index 00000000000..a14bb788909 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/education/lifetime_learning_credit/lifetime_learning_credit_phase_out.yaml @@ -0,0 +1,17 @@ +- name: Income excluded under section 911 counts toward the phase-out + period: 2024 + input: + adjusted_gross_income: 78_000 + foreign_earned_income_exclusion: 4_000 + output: + # 26 U.S.C. 25A(d)(2): modified AGI 82,000; (82,000 - 80,000) / 10,000. + lifetime_learning_credit_phase_out: 0.2 + +- name: Income excluded under section 931 counts toward the pre-2021 phase-out + period: 2020 + input: + adjusted_gross_income: 58_000 + specified_possession_income: 6_000 + output: + # Modified AGI 64,000; (64,000 - 59,000) / 10,000. + lifetime_learning_credit_phase_out: 0.5 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml index 69844ea10a0..8fec9eeaef1 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_person.yaml @@ -275,3 +275,22 @@ adjusted_gross_income: 24_250 output: savers_credit_person: [1_000] + +- name: Income excluded under section 911 counts toward the rate thresholds + period: 2024 + input: + people: + person1: + savers_credit_eligible_person: true + traditional_ira_contributions_desired: 2_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 22_000 + foreign_earned_income_exclusion: 2_000 + output: + # 26 U.S.C. 25B(e): AGI without regard to section 911 is 24,000, or + # 48,000 on the joint schedule (divided by 0.5), so the rate is 20%, + # not the 50% that 22,000 (44,000) would give. + savers_credit_person: 400 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml index d376886c3f0..bdbe118ede6 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/deductions/itemized/salt_and_real_estate/salt_deduction.yaml @@ -111,3 +111,20 @@ state_and_local_sales_or_income_tax: 0 output: salt_deduction: 11_000 + +- name: Income excluded under section 911 counts toward the cap phase-down + period: 2025 + input: + people: + person1: + real_estate_taxes: 50_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + adjusted_gross_income: 490_000 + foreign_earned_income_exclusion: 20_000 + output: + # 26 U.S.C. 164(b)(7)(B)(iv): modified AGI 510,000; 40,000 - 30% x 10,000. + salt_cap: 37_000 + salt_deduction: 37_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml new file mode 100644 index 00000000000..47d3a93caa6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml @@ -0,0 +1,34 @@ +- name: AGI only, nothing excluded + period: 2024 + input: + adjusted_gross_income: 50_000 + output: + agi_plus_section_911_931_933_exclusions: 50_000 + +- name: All three exclusions are added back + period: 2024 + input: + adjusted_gross_income: 50_000 + foreign_earned_income_exclusion: 30_000 + specified_possession_income: 20_000 + puerto_rico_income: 10_000 + output: + agi_plus_section_911_931_933_exclusions: 110_000 + +- name: Built from income, the section 931 and 933 deductions are reversed + period: 2024 + input: + people: + person1: + irs_employment_income: 100_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion: 15_000 + specified_possession_income: 20_000 + puerto_rico_income: 10_000 + output: + # Income inputs are net of the section 911 exclusion; sections 931 and + # 933 are above-the-line deductions. AGI 100,000 - 30,000 = 70,000. + adjusted_gross_income: 70_000 + agi_plus_section_911_931_933_exclusions: 115_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml index c67d9804d92..ff5e3321024 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/auto_loan_interest_deduction.yaml @@ -36,3 +36,15 @@ output: auto_loan_interest_deduction: 0 taxable_income_deductions: 0 + +- name: Income excluded under section 933 counts toward the phase-out + period: 2025 + input: + adjusted_gross_income: 95_000 + puerto_rico_income: 10_000 + filing_status: SINGLE + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # 26 U.S.C. 163(h)(4)(C)(ii)(II): modified AGI 105,000; five $1,000 + # steps over 100,000 at $200 each. + auto_loan_interest_deduction: 4_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.yaml index a778bc40b41..725bb41ed84 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.yaml @@ -37,3 +37,15 @@ filing_status: JOINT output: overtime_income_deduction: 10_000 + +- name: Income excluded under section 931 counts toward the phase-out + period: 2025 + input: + overtime_income_deduction_ssn_requirement_met: true + fsla_overtime_premium: 10_000 + adjusted_gross_income: 140_000 + specified_possession_income: 20_000 + filing_status: SINGLE + output: + # 26 U.S.C. 225(b)(2)(B): modified AGI 160,000; (160,000 - 150,000) x 10%. + overtime_income_deduction: 9_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.yaml index de676f0d455..1ba81935df0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.yaml @@ -160,3 +160,16 @@ output: tip_income_deduction_occupation_requirement_met: false tip_income_deduction: 0 + +- name: Income excluded under section 911 counts toward the phase-out + period: 2025 + input: + tip_income_deduction_ssn_requirement_met: true + treasury_tipped_occupation_code: 102 + tip_income: 10_000 + adjusted_gross_income: 140_000 + foreign_earned_income_exclusion: 20_000 + filing_status: SINGLE + output: + # 26 U.S.C. 224(b)(2)(B): modified AGI 160,000; (160,000 - 150,000) x 10%. + tip_income_deduction: 9_000 diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py index 4cfd3055425..90d0cde9b9e 100644 --- a/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py @@ -24,9 +24,11 @@ def formula(tax_unit, period, parameters): # Capacity limit applies with and without the Inflation Reduction Act. capacity = tax_unit("new_clean_vehicle_battery_capacity", period) meets_capacity_requirement = capacity >= p.min_kwh - agi = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 30D(f)(10)(C): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) filing_status = tax_unit("filing_status", period) - meets_income_limit = agi <= p.income_limit[filing_status] + meets_income_limit = magi <= p.income_limit[filing_status] msrp = tax_unit("new_clean_vehicle_msrp", period) classification = tax_unit("new_clean_vehicle_classification", period) meets_msrp_limit = msrp <= p.msrp_limit[classification] diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py index f4890b5288a..a5ad6523fea 100644 --- a/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py @@ -20,11 +20,13 @@ def formula(tax_unit, period, parameters): if not p.eligibility.in_effect: return False # Income eligibility based on lesser of MAGI in current and prior year. - # Assume AGI in current year for now. - agi = tax_unit("adjusted_gross_income", period) + # Assume current-year MAGI for now. 26 U.S.C. 25E(b)(3): modified + # adjusted gross income adds back income excluded under sections + # 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) filing_status = tax_unit("filing_status", period) income_limit = p.eligibility.income_limit[filing_status] - income_eligible = agi <= income_limit + income_eligible = magi <= income_limit # Purchase price limit. sale_price = tax_unit("used_clean_vehicle_sale_price", period) price_eligible = sale_price <= p.eligibility.sale_price_limit diff --git a/policyengine_us/variables/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.py b/policyengine_us/variables/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.py index d56b0feaab3..12c62c8c018 100644 --- a/policyengine_us/variables/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.py +++ b/policyengine_us/variables/gov/irs/credits/ctc/phase_out/arpa/ctc_arpa_uncapped_phase_out.py @@ -19,7 +19,9 @@ def formula(tax_unit, period, parameters): # applying before and only to the increase in the maximum CTC under ARPA. # Calculate the income used to assess the new phase-out. threshold = tax_unit("ctc_arpa_phase_out_threshold", period) - agi = tax_unit("adjusted_gross_income", period) - excess = max_(0, agi - threshold) + # Schedule 8812 (2021) line 3: the modified AGI of 24(b)(1), which + # adds back income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) + excess = max_(0, magi - threshold) increments = ceil(excess / p.increment) return increments * p.amount diff --git a/policyengine_us/variables/gov/irs/credits/ctc/phase_out/ctc_phase_out.py b/policyengine_us/variables/gov/irs/credits/ctc/phase_out/ctc_phase_out.py index 24e5e0976fe..37dd49572c5 100644 --- a/policyengine_us/variables/gov/irs/credits/ctc/phase_out/ctc_phase_out.py +++ b/policyengine_us/variables/gov/irs/credits/ctc/phase_out/ctc_phase_out.py @@ -18,7 +18,9 @@ def formula(tax_unit, period, parameters): # applying before and only to the increase in the maximum CTC under ARPA. # Start with the normal phase-out. - income = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 24(b)(1): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + income = tax_unit("agi_plus_section_911_931_933_exclusions", period) p = parameters(period).gov.irs.credits.ctc.phase_out phase_out_threshold = tax_unit("ctc_phase_out_threshold", period) excess = max_(0, income - phase_out_threshold) diff --git a/policyengine_us/variables/gov/irs/credits/education/education_credit_phase_out.py b/policyengine_us/variables/gov/irs/credits/education/education_credit_phase_out.py index 8cbdb0a2b4c..5ddd37f9c35 100644 --- a/policyengine_us/variables/gov/irs/credits/education/education_credit_phase_out.py +++ b/policyengine_us/variables/gov/irs/credits/education/education_credit_phase_out.py @@ -11,7 +11,9 @@ class education_credit_phase_out(Variable): def formula(tax_unit, period, parameters): education = parameters(period).gov.irs.credits.education - agi = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 25A(d)(2): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) is_joint = tax_unit("tax_unit_is_joint", period) phase_out_start = where( is_joint, @@ -23,5 +25,5 @@ def formula(tax_unit, period, parameters): education.phase_out.length.joint, education.phase_out.length.single, ) - excess_agi = max_(0, agi - phase_out_start) - return min_(1, excess_agi / phase_out_length) + excess_magi = max_(0, magi - phase_out_start) + return min_(1, excess_magi / phase_out_length) diff --git a/policyengine_us/variables/gov/irs/credits/education/lifetime_learning_credit_phase_out.py b/policyengine_us/variables/gov/irs/credits/education/lifetime_learning_credit_phase_out.py index f55bc0d8d90..813b6454648 100644 --- a/policyengine_us/variables/gov/irs/credits/education/lifetime_learning_credit_phase_out.py +++ b/policyengine_us/variables/gov/irs/credits/education/lifetime_learning_credit_phase_out.py @@ -18,7 +18,9 @@ class lifetime_learning_credit_phase_out(Variable): def formula(tax_unit, period, parameters): llc = parameters(period).gov.irs.credits.education.lifetime_learning_credit - agi = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 25A(d)(2): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) is_joint = tax_unit("tax_unit_is_joint", period) phase_out_start = where( is_joint, @@ -30,5 +32,5 @@ def formula(tax_unit, period, parameters): llc.phase_out.length.joint, llc.phase_out.length.single, ) - excess_agi = max_(0, agi - phase_out_start) - return min_(1, excess_agi / phase_out_length) + excess_magi = max_(0, magi - phase_out_start) + return min_(1, excess_magi / phase_out_length) diff --git a/policyengine_us/variables/gov/irs/credits/retirement_savings/savers_credit_person.py b/policyengine_us/variables/gov/irs/credits/retirement_savings/savers_credit_person.py index f9cf45ed373..aef64786ddc 100644 --- a/policyengine_us/variables/gov/irs/credits/retirement_savings/savers_credit_person.py +++ b/policyengine_us/variables/gov/irs/credits/retirement_savings/savers_credit_person.py @@ -24,7 +24,9 @@ def formula(person, period, parameters): qualified_contributions, p.contributions_cap ) - agi = person.tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 25B(e): adjusted gross income is determined without + # regard to sections 911, 931 and 933. + agi = person.tax_unit("agi_plus_section_911_931_933_exclusions", period) # AGI threshold for the rate filing_status = person.tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py new file mode 100644 index 00000000000..9bbfbc42a4c --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py @@ -0,0 +1,39 @@ +from policyengine_us.model_api import * + + +class agi_plus_section_911_931_933_exclusions(Variable): + value_type = float + entity = TaxUnit + label = "AGI plus income excluded under sections 911, 931 and 933" + unit = USD + documentation = ( + "Adjusted gross income increased by any amount excluded from gross " + "income under 26 U.S.C. 911 (foreign earned income and housing), " + "931 (income from Guam, American Samoa or the Northern Mariana " + "Islands) or 933 (income from Puerto Rico). This is the modified " + "adjusted gross income of the child tax credit, the education " + "credits, the saver's credit, the clean vehicle credits, the SALT " + "cap phase-down and the senior, tip, overtime and car loan interest " + "deductions. Income inputs are net of the section 911 exclusion, so " + "it is added back; sections 931 and 933 are above-the-line " + "deductions in this model, so adding them back reverses them." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/24#b_1", + "https://www.law.cornell.edu/uscode/text/26/25A#d_2", + "https://www.law.cornell.edu/uscode/text/26/25B#e", + "https://www.law.cornell.edu/uscode/text/26/25E#b_3", + "https://www.law.cornell.edu/uscode/text/26/30D#f_10_C", + "https://www.law.cornell.edu/uscode/text/26/151#d_5_C", + "https://www.law.cornell.edu/uscode/text/26/163#h_4_C", + "https://www.law.cornell.edu/uscode/text/26/164#b_7_B", + "https://www.law.cornell.edu/uscode/text/26/224#b_2_B", + "https://www.law.cornell.edu/uscode/text/26/225#b_2_B", + ) + adds = [ + "adjusted_gross_income", + "foreign_earned_income_exclusion", + "specified_possession_income", + "puerto_rico_income", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py index 102efb96f39..c12ab856515 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py @@ -26,9 +26,12 @@ def formula(tax_unit, period, parameters): # Get the phaseout start amount based on filing status (line 4). phaseout_start = p.phase_out.start[filing_status] - agi_pre_ald = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 163(h)(4)(C)(ii)(II): modified adjusted gross income adds + # back income excluded under sections 911, 931 and 933 (Schedule 1-A, + # line 3). + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) # Get the excess amount, if any, in thousands of dollars (rounded up) [lines 5 and 6]. - excess = max_(agi_pre_ald - phaseout_start, 0) + excess = max_(magi - phaseout_start, 0) increments = np.ceil(excess / p.phase_out.increment) # Calculate the excess part phase out amount (line 7). diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py index 890bde74376..04420ec2969 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/salt_cap.py @@ -14,9 +14,11 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.deductions.itemized.salt_and_real_estate max_cap = p.cap[filing_status] if p.phase_out.in_effect: - agi = tax_unit("adjusted_gross_income", period) - agi_excess = max_(0, agi - p.phase_out.threshold[filing_status]) - phase_out = p.phase_out.rate * agi_excess + # 26 U.S.C. 164(b)(7)(B)(iv): modified adjusted gross income adds + # back income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) + magi_excess = max_(0, magi - p.phase_out.threshold[filing_status]) + phase_out = p.phase_out.rate * magi_excess phased_out_cap = max_(0, max_cap - phase_out) if p.phase_out.floor.applies: floor = p.phase_out.floor.amount[filing_status] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.py index 035caa0f059..3561b92ccde 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/overtime_income/overtime_income_deduction.py @@ -12,13 +12,15 @@ class overtime_income_deduction(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members overtime_income = person("fsla_overtime_premium", period) - agi = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 225(b)(2)(B): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) filing_status = tax_unit("filing_status", period) p = parameters(period).gov.irs.deductions.overtime_income cap = p.cap[filing_status] start = p.phase_out.start[filing_status] - agi_excess = max_(agi - start, 0) - phase_out_amount = agi_excess * p.phase_out.rate + magi_excess = max_(magi - start, 0) + phase_out_amount = magi_excess * p.phase_out.rate total_overtime_income = tax_unit.sum(overtime_income) capped_overtime_income = min_(cap, total_overtime_income) return max_(0, capped_overtime_income - phase_out_amount) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.py index fa666bf0e9e..2d32fe38f34 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.py @@ -8,19 +8,10 @@ class additional_senior_deduction_magi(Variable): unit = USD definition_period = YEAR reference = ( + "https://www.law.cornell.edu/uscode/text/26/151#d_5_C", "https://www.congress.gov/119/bills/hr1/BILLS-119hr1enr.pdf#page=88", "https://www.irs.gov/pub/irs-pdf/f1040s1a.pdf#page=1", ) - - def formula(tax_unit, period, parameters): - agi = tax_unit("adjusted_gross_income", period) - excluded_income = add( - tax_unit, - period, - [ - "foreign_earned_income_exclusion", - "specified_possession_income", - "puerto_rico_income", - ], - ) - return agi + excluded_income + # 26 U.S.C. 151(d)(5)(C)(iii)(II): adjusted gross income increased by + # any amount excluded from gross income under section 911, 931, or 933. + adds = ["agi_plus_section_911_931_933_exclusions"] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.py index 913109f3d3c..31e679844c4 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tip_income/tip_income_deduction.py @@ -15,12 +15,14 @@ def formula(tax_unit, period, parameters): occupation_requirement_met = person( "tip_income_deduction_occupation_requirement_met", period ) - agi = tax_unit("adjusted_gross_income", period) + # 26 U.S.C. 224(b)(2)(B): modified adjusted gross income adds back + # income excluded under sections 911, 931 and 933. + magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) filing_status = tax_unit("filing_status", period) p = parameters(period).gov.irs.deductions.tip_income start = p.phase_out.start[filing_status] - agi_excess = max_(agi - start, 0) - phase_out_amount = agi_excess * p.phase_out.rate + magi_excess = max_(magi - start, 0) + phase_out_amount = magi_excess * p.phase_out.rate qualified_tip_income = tip_income * occupation_requirement_met total_tip_income = tax_unit.sum(qualified_tip_income) capped_tip_income = min_(p.cap, total_tip_income) From 6c275fef026a4f13694bf632be7e9ae9ce3c7961 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:22:47 -0400 Subject: [PATCH 3/8] Add back section 911 in the Social Security, NIIT and IRMAA MAGIs - Taxable Social Security, 26 U.S.C. 86(b)(2)(A): determined without regard to section 911. The revoked-deductions list already covered sections 931 and 933; the 911 exclusion is netted out of the income inputs, so taxable_ss_magi adds it back. - Net investment income tax, 26 U.S.C. 1411(d): adds back the section 911(a)(1) exclusion (Form 8960 line 13 worksheet). The model's one exclusion input also holds the housing amounts, which the worksheet leaves out; niit_magi documents that. - Medicare IRMAA, 42 U.S.C. 1395r(i)(4)(A): determined without regard to IRC sections 135, 911, 931 and 933, plus tax-exempt interest. All four are added back, for both the modeled and the provided lag years. Co-Authored-By: Claude Opus 5.5 --- .../medicare_irmaa_magi_two_years_prior.yaml | 36 ++++++++++++++ .../social_security/taxable_ss_magi.yaml | 48 +++++++++++++++++++ .../gov/irs/tax/federal_income/niit_magi.yaml | 12 +++++ .../medicare_irmaa_magi_two_years_prior.py | 33 +++++++++---- .../social_security/taxable_ss_magi.py | 3 ++ .../gov/irs/tax/federal_income/niit_magi.py | 24 ++++++++-- 6 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index f0064f8cdb8..a23513bb44b 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -73,3 +73,39 @@ medicare_enrolled: true output: income_adjusted_part_d_premium_surcharge: 0 + +- name: Amounts excluded under IRC sections 135, 911, 931 and 933 are added back. + period: 2026 + input: + age: 66 + adjusted_gross_income: + 2024: 100_000 + tax_exempt_interest_income: + 2024: 2_000 + us_bonds_for_higher_ed: + 2024: 1_000 + foreign_earned_income_exclusion: + 2024: 10_000 + specified_possession_income: + 2024: 3_000 + puerto_rico_income: + 2024: 4_000 + output: + # 42 U.S.C. 1395r(i)(4)(A): 100,000 + 2,000 + 1,000 + 10,000 + 3,000 + 4,000. + medicare_irmaa_magi_two_years_prior: 120_000 + +- name: Excluded amounts provided for an unmodeled lag year are added back too. + period: 2016 + input: + age: 66 + adjusted_gross_income: + 2014: 100_000 + us_bonds_for_higher_ed: + 2014: 1_000 + 2016: 0 + foreign_earned_income_exclusion: + 2014: 10_000 + puerto_rico_income: + 2014: 4_000 + output: + medicare_irmaa_magi_two_years_prior: 115_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml new file mode 100644 index 00000000000..ec429eb8aeb --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml @@ -0,0 +1,48 @@ +- name: Income excluded under section 911 is added back + period: 2024 + input: + people: + person1: + age: 67 + irs_employment_income: 20_000 + social_security_retirement: 20_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + foreign_earned_income_exclusion: 10_000 + households: + household: + members: [person1] + state_code: TX + output: + # 26 U.S.C. 86(b)(2)(A): 20,000 of wages plus 10,000 excluded under + # section 911. Combined income 30,000 + 10,000 = 40,000 is over the + # 34,000 adjusted base: 85% x 6,000 + min(7,500, 4,500) = 9,600. + taxable_ss_magi: 30_000 + tax_unit_taxable_social_security: 9_600 + +- name: Income excluded under sections 931 and 933 is added back + period: 2024 + input: + people: + person1: + age: 67 + irs_employment_income: 30_000 + social_security_retirement: 20_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + specified_possession_income: 6_000 + puerto_rico_income: 4_000 + households: + household: + members: [person1] + state_code: TX + output: + # The section 931 and 933 deductions are not taken: 30,000. Taxable + # benefits are 9,600 as above, so AGI is 30,000 + 9,600 - 10,000. + taxable_ss_magi: 30_000 + tax_unit_taxable_social_security: 9_600 + adjusted_gross_income: 29_600 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..f43b831dc93 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,15 @@ output: adjusted_gross_income: 160_000 niit_magi: 160_000 + +- name: Income excluded under section 911 is added back + period: 2024 + input: + adjusted_gross_income: 190_000 + foreign_earned_income_exclusion: 20_000 + net_investment_income: 30_000 + filing_status: SINGLE + output: + # 26 U.S.C. 1411(d): modified AGI 210,000; tax 3.8% x (210,000 - 200,000). + niit_magi: 210_000 + net_investment_income_tax: 380 diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index b5efcf83026..a89a561f28f 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -1,6 +1,18 @@ from policyengine_us.model_api import * from policyengine_us.tools.parameters import FIRST_MODELED_YEAR +# 42 U.S.C. 1395r(i)(4)(A): adjusted gross income (i) determined without +# regard to sections 135, 911, 931 and 933 of the Internal Revenue Code and +# (ii) increased by tax-exempt interest. Income inputs are net of the section +# 911 exclusion; sections 135, 931 and 933 are above-the-line deductions in +# this model. Adding each amount back undoes it. +TAX_UNIT_ADDITIONS = [ + "foreign_earned_income_exclusion", + "specified_possession_income", + "puerto_rico_income", +] +PERSON_ADDITIONS = ["tax_exempt_interest_income", "us_bonds_for_higher_ed"] + class medicare_irmaa_magi_two_years_prior(Variable): value_type = float @@ -8,12 +20,14 @@ class medicare_irmaa_magi_two_years_prior(Variable): label = "Medicare IRMAA MAGI from two years prior" unit = USD definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/42/1395r" + reference = "https://www.law.cornell.edu/uscode/text/42/1395r#i_4" documentation = ( "Modified adjusted gross income used to determine Medicare IRMAA " "charges. Callers may provide this value directly for the current " - "benefit year. When it is not provided, PolicyEngine computes it as " - "adjusted gross income plus tax-exempt interest from two years prior. " + "benefit year. When it is not provided, PolicyEngine computes it from " + "two years prior as adjusted gross income plus tax-exempt interest " + "and the amounts excluded or deducted under IRC sections 135, 911, " + "931 and 933. " "Single-year datasets without lagged income inputs therefore default " "to the modeled prior-year values, which may be zero. When the year " "two years prior precedes the first year PolicyEngine models (2015), " @@ -26,7 +40,7 @@ def formula(tax_unit, period, parameters): return add( tax_unit, prior_period, - ["adjusted_gross_income", "tax_exempt_interest_income"], + ["adjusted_gross_income", *TAX_UNIT_ADDITIONS, *PERSON_ADDITIONS], ) # Benefit years 2015 and 2016 look back to 2013 and 2014, before the # parameters begin, so income for those years cannot be computed. Use @@ -40,8 +54,9 @@ def provided(variable, population): value = holder.get_array(prior_period, simulation.branch_name) return population.empty_array() if value is None else value - agi = provided("adjusted_gross_income", tax_unit) - tax_exempt_interest = tax_unit.sum( - provided("tax_exempt_interest_income", tax_unit.members) - ) - return agi + tax_exempt_interest + magi = provided("adjusted_gross_income", tax_unit) + for variable in TAX_UNIT_ADDITIONS: + magi = magi + provided(variable, tax_unit) + for variable in PERSON_ADDITIONS: + magi = magi + tax_unit.sum(provided(variable, tax_unit.members)) + return magi diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py index 0e8a6beb1fb..72653e24bc5 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py @@ -35,6 +35,9 @@ def formula(tax_unit, period, parameters): # Add positive values only - losses are deducted later. gross_income += not_dependent * max_(0, add(person, period, [source])) gross_income = tax_unit.sum(gross_income) + # IRC 86(b)(2)(A): determined without regard to section 911. Income + # inputs are net of the foreign earned income exclusion; add it back. + gross_income += tax_unit("foreign_earned_income_exclusion", period) above_the_line_deductions = irs.ald.deductions revoked_deductions = ss_magi.revoked_deductions deductions = [ 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..392849ade9f 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 @@ -8,10 +8,15 @@ class niit_magi(Variable): unit = USD 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." + "income plus the section 911 foreign earned income exclusion and 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. " + "Section 1411(d) adds back only the section 911(a)(1) exclusion, net " + "of deductions disallowed under section 911(d)(6) (Line 13 MAGI " + "Worksheet, line 2c); the model's single foreign_earned_income_exclusion " + "input also holds the housing exclusion and housing deduction, so a " + "filer who claims those adds back more than Form 8960 does. The CFC and PFIC " + "adjustments of Treas. Reg. 1.1411-10(e) are not modeled." ) definition_period = YEAR reference = ( @@ -27,4 +32,13 @@ 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 + # 26 U.S.C. 1411(d): income inputs are net of the section 911 + # exclusion, so add it back. + foreign_earned_income_exclusion = tax_unit( + "foreign_earned_income_exclusion", period + ) + return ( + tax_unit("adjusted_gross_income", period) + + foreign_earned_income_exclusion + + estate_magi_adjustment + ) From e3c74055ca36295cfb908a7c140fe157e2ca176b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:23:01 -0400 Subject: [PATCH 4/8] Test the modified AGI add-backs on random households Differential: on every return, student-loan MAGI (built from gross income sources) equals AGI plus the student loan interest deduction, the section 911, 931 and 933 amounts and any unemployment compensation section 85(c) excluded; the shared modified AGI equals AGI plus the three amounts. Shifting income into an excluded category at otherwise fixed AGI raises student-loan MAGI and taxable_ss_magi by exactly the amount shifted, never raises the student loan interest deduction, and moves every phase- out the shared modified AGI feeds in one direction only. Co-Authored-By: Claude Opus 5.5 --- .../fix-student-loan-magi-931-911.fixed.md | 1 + .../tests/test_modified_agi_add_backs.py | 398 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 changelog.d/fix-student-loan-magi-931-911.fixed.md create mode 100644 policyengine_us/tests/test_modified_agi_add_backs.py diff --git a/changelog.d/fix-student-loan-magi-931-911.fixed.md b/changelog.d/fix-student-loan-magi-931-911.fixed.md new file mode 100644 index 00000000000..b15b6919377 --- /dev/null +++ b/changelog.d/fix-student-loan-magi-931-911.fixed.md @@ -0,0 +1 @@ +Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security and the net investment income tax add back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. diff --git a/policyengine_us/tests/test_modified_agi_add_backs.py b/policyengine_us/tests/test_modified_agi_add_backs.py new file mode 100644 index 00000000000..1816d22f634 --- /dev/null +++ b/policyengine_us/tests/test_modified_agi_add_backs.py @@ -0,0 +1,398 @@ +"""Property and differential tests for the modified AGI add-backs of 26 U.S.C. +sections 911, 931 and 933. + +26 U.S.C. 221(b)(2)(C) defines the modified adjusted gross income of the +student loan interest deduction as adjusted gross income "determined (i) +without regard to this section and sections 85(c), 911, 931, and 933, and +(ii) after application of sections 86, 135, 137, 219, and 469". Sections +24(b)(1), 25A(d)(2), 25B(e), 25E(b)(3), 30D(f)(10)(C), 151(d)(5)(C), +163(h)(4)(C), 164(b)(7)(B), 224(b)(2)(B) and 225(b)(2)(B) each use adjusted +gross income increased by any amount excluded under sections 911, 931 or +933; the model computes that once, as +`agi_plus_section_911_931_933_exclusions`. Section 86(b)(2) (taxable Social +Security) and section 1411(d) (net investment income tax) add back the +section 911 exclusion too. + +In the model, income inputs are net of the section 911 exclusion, and +sections 931 and 933 are above-the-line deductions. A household with +possession or Puerto Rico income therefore reports it in wages and deducts +it again. + +Two kinds of test, on random households: + +- Differential. Student-loan MAGI is built from gross income sources with + the excluded deductions left in; adjusted gross income is built from + gross income less every deduction. On every return, MAGI must equal AGI + plus the student loan interest deduction, the three excluded amounts, and + any unemployment compensation section 85(c) excluded (2020). The shared + modified AGI must equal AGI plus the three excluded amounts. +- Shifting income into an excluded category while every other part of AGI + stays fixed (raising the section 911 exclusion, or raising wages together + with the section 931 or 933 deduction) raises student-loan MAGI by exactly + the amount shifted, never raises the student loan interest deduction, and + moves every phase-out the shared modified AGI feeds in one direction only: + no reduction falls and no deduction, cap or eligibility rises. AGI itself + moves only by the fall in the student loan interest deduction and, in + 2020, by any unemployment compensation that fall makes taxable. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +STATUSES = ["SINGLE", "JOINT", "HEAD_OF_HOUSEHOLD"] +EXCLUSIONS = [ + "foreign_earned_income_exclusion", + "specified_possession_income", + "puerto_rico_income", +] +YEARS = [2020, 2024, 2025, 2026] + +# Outputs that must not fall when excluded income rises (reductions). +NON_DECREASING = [ + "ctc_phase_out", + "education_credit_phase_out", + "lifetime_learning_credit_phase_out", +] +# Outputs that must not rise (deductions, caps, eligibility). +NON_INCREASING = [ + "student_loan_interest_ald", + "tip_income_deduction", + "overtime_income_deduction", + "auto_loan_interest_deduction", + "salt_cap", + "new_clean_vehicle_credit_eligible", + "used_clean_vehicle_credit_eligible", +] +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "agi_plus_section_911_931_933_exclusions", + "additional_senior_deduction_magi", + "tax_unit_taxable_unemployment_compensation", + "taxable_ss_magi", + "niit_magi", + *NON_DECREASING, + *NON_INCREASING, +] +# Person-level outputs summed over the return. +SUMMED_OUTPUTS = [ + "student_loan_interest_ald_magi", + "student_loan_interest_ald", + "unemployment_compensation", +] + + +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: 40}, + # Possession and Puerto Rico income is part of wages and is + # deducted again above the line. + "employment_income": { + year: h["wages"] + + h["specified_possession_income"] + + h["puerto_rico_income"] + }, + "unemployment_compensation": {year: h["unemployment_compensation"]}, + "student_loan_interest": {year: h["student_loan_interest"]}, + "tip_income": {year: h["tips"]}, + "treasury_tipped_occupation_code": {year: 102}, + "fsla_overtime_premium": {year: h["overtime"]}, + "real_estate_taxes": {year: h["real_estate_taxes"]}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = { + "age": {year: 40}, + "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: 10}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + # Set for every tax unit so none falls back to the default. + "filing_status": {year: h["status"]}, + "foreign_earned_income_exclusion": { + year: h["foreign_earned_income_exclusion"] + }, + "specified_possession_income": {year: h["specified_possession_income"]}, + "puerto_rico_income": {year: h["puerto_rico_income"]}, + "tip_income_deduction_ssn_requirement_met": {year: True}, + "overtime_income_deduction_ssn_requirement_met": {year: True}, + "purchased_qualifying_new_clean_vehicle": {year: h["bought_vehicle"]}, + "new_clean_vehicle_battery_capacity": {year: 10}, + "new_clean_vehicle_msrp": {year: 40_000}, + "purchased_qualifying_used_clean_vehicle": {year: h["bought_vehicle"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + "qualified_passenger_vehicle_loan_interest": {year: h["car_loan_interest"]}, + } + 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 calculate(households, year): + simulation = Simulation(situation=build_situation(households, year)) + results = { + v: np.asarray(simulation.calculate(v, year), dtype=float) + for v in TAX_UNIT_OUTPUTS + } + for v in SUMMED_OUTPUTS: + results[v] = np.asarray( + simulation.calculate(v, year, map_to="tax_unit"), dtype=float + ) + return results + + +def shifted(household, category, amount): + """The same household with `amount` more income in an excluded category. + + The section 911 exclusion is not in the income inputs, so raising it + leaves AGI alone. Section 931 and 933 income is in wages and deducted + again, so raising it raises both and leaves AGI alone. + """ + return {**household, category: household[category] + amount} + + +def tolerance(*amounts): + # Single-precision storage: allow a few units in the last place. + return 1 + 1e-6 * max(abs(a) for a in amounts) + + +def assert_differential(households, results): + for i, h in enumerate(households): + r = {v: results[v][i] for v in results} + excluded = sum(h[c] for c in EXCLUSIONS) + uc_excluded = ( + r["unemployment_compensation"] + - r["tax_unit_taxable_unemployment_compensation"] + ) + expected = ( + r["adjusted_gross_income"] + + r["student_loan_interest_ald"] + + excluded + + uc_excluded + ) + assert r["student_loan_interest_ald_magi"] == pytest.approx( + expected, abs=tolerance(expected) + ), (h, r) + assert r["agi_plus_section_911_931_933_exclusions"] == pytest.approx( + r["adjusted_gross_income"] + excluded, + abs=tolerance(r["adjusted_gross_income"], excluded), + ), (h, r) + assert ( + r["additional_senior_deduction_magi"] + == r["agi_plus_section_911_931_933_exclusions"] + ), (h, r) + # The deduction is capped at the interest paid and $2,500. + assert ( + 0 + <= r["student_loan_interest_ald"] + <= min(h["student_loan_interest"], 2_500) + tolerance(2_500) + ), (h, r) + + +def assert_shift_invariants(households, shifts, year): + pairs = [] + for h, (category, amount) in zip(households, shifts): + pairs += [h, shifted(h, category, amount)] + results = calculate(pairs, year) + assert_differential(pairs, results) + base = {v: a[0::2] for v, a in results.items()} + more = {v: a[1::2] for v, a in results.items()} + for i, (h, (category, amount)) in enumerate(zip(households, shifts)): + context = (h, category, amount, year) + b = {v: base[v][i] for v in base} + m = {v: more[v][i] for v in more} + # Student-loan MAGI rises by exactly the amount shifted. + rise = m["student_loan_interest_ald_magi"] - b["student_loan_interest_ald_magi"] + assert rise == pytest.approx( + amount, abs=tolerance(m["student_loan_interest_ald_magi"]) + ), context + # AGI moves only through the student loan interest deduction and, + # in 2020, the unemployment compensation its fall makes taxable + # (the section 85(c) income test subtracts the deduction). + agi_change = m["adjusted_gross_income"] - b["adjusted_gross_income"] + deduction_change = ( + b["student_loan_interest_ald"] - m["student_loan_interest_ald"] + ) + taxable_uc_change = ( + m["tax_unit_taxable_unemployment_compensation"] + - b["tax_unit_taxable_unemployment_compensation"] + ) + assert deduction_change >= -tolerance(2_500), context + assert agi_change == pytest.approx( + deduction_change + taxable_uc_change, + abs=tolerance(m["adjusted_gross_income"]), + ), context + # Section 86(b)(2) MAGI adds back all three and has no deduction for + # student loan interest, so it rises by exactly the amount shifted. + ss_rise = m["taxable_ss_magi"] - b["taxable_ss_magi"] + assert ss_rise == pytest.approx(amount, abs=tolerance(m["taxable_ss_magi"])), ( + context + ) + slack = 1e-6 + for v in NON_DECREASING + ["agi_plus_section_911_931_933_exclusions"]: + assert m[v] >= b[v] - slack * max(1, abs(b[v])), (v, context) + for v in NON_INCREASING: + assert m[v] <= b[v] + slack * max(1, abs(b[v])), (v, context) + # Section 1411(d) adds back only section 911. + niit_rise = m["niit_magi"] - b["niit_magi"] + expected_niit_rise = agi_change + ( + amount if category == "foreign_earned_income_exclusion" else 0 + ) + assert niit_rise == pytest.approx( + expected_niit_rise, abs=tolerance(m["niit_magi"]) + ), context + + +household_strategy = st.fixed_dictionaries( + { + "status": st.sampled_from(STATUSES), + "wages": st.integers(0, 400_000), + "spouse_wages": st.one_of(st.just(0), st.integers(1, 200_000)), + "unemployment_compensation": st.one_of(st.just(0), st.integers(1, 30_000)), + "student_loan_interest": st.one_of(st.just(0), st.integers(1, 4_000)), + "foreign_earned_income_exclusion": st.one_of( + st.just(0), st.integers(1, 130_000) + ), + "specified_possession_income": st.one_of(st.just(0), st.integers(1, 100_000)), + "puerto_rico_income": st.one_of(st.just(0), st.integers(1, 100_000)), + "tips": st.one_of(st.just(0), st.integers(1, 30_000)), + "overtime": st.one_of(st.just(0), st.integers(1, 20_000)), + "car_loan_interest": st.one_of(st.just(0), st.integers(1, 12_000)), + "real_estate_taxes": st.one_of(st.just(0), st.integers(1, 60_000)), + "bought_vehicle": st.booleans(), + } +) +shift_strategy = st.tuples(st.sampled_from(EXCLUSIONS), st.integers(1, 100_000)) + +# Households in each phase-out range, so the shift crosses a threshold. +GRID = [ + # 2024 student loan phase-out: $80,000-$95,000 single. + dict( + status="SINGLE", + wages=75_000, + spouse_wages=0, + unemployment_compensation=0, + student_loan_interest=2_500, + foreign_earned_income_exclusion=0, + specified_possession_income=0, + puerto_rico_income=0, + tips=10_000, + overtime=10_000, + car_loan_interest=5_000, + real_estate_taxes=50_000, + bought_vehicle=True, + ), + # Joint, with unemployment compensation and all three exclusions. + dict( + status="JOINT", + wages=120_000, + spouse_wages=30_000, + unemployment_compensation=8_000, + student_loan_interest=3_000, + foreign_earned_income_exclusion=5_000, + specified_possession_income=4_000, + puerto_rico_income=3_000, + tips=0, + overtime=15_000, + car_loan_interest=9_000, + real_estate_taxes=20_000, + bought_vehicle=True, + ), + # Head of household near the tip, overtime and car loan phase-outs. + dict( + status="HEAD_OF_HOUSEHOLD", + wages=140_000, + spouse_wages=0, + unemployment_compensation=0, + student_loan_interest=1_000, + foreign_earned_income_exclusion=0, + specified_possession_income=0, + puerto_rico_income=0, + tips=20_000, + overtime=10_000, + car_loan_interest=10_000, + real_estate_taxes=0, + bought_vehicle=False, + ), + # High income, near the SALT cap phase-down and the CTC phase-out. + dict( + status="SINGLE", + wages=480_000, + spouse_wages=0, + unemployment_compensation=0, + student_loan_interest=0, + foreign_earned_income_exclusion=0, + specified_possession_income=0, + puerto_rico_income=0, + tips=0, + overtime=0, + car_loan_interest=0, + real_estate_taxes=60_000, + bought_vehicle=False, + ), +] +GRID_SHIFTS = [(category, 15_000) for category in EXCLUSIONS] + + +@pytest.mark.parametrize("year", YEARS) +def test_grid_keeps_the_invariants(year): + households = [h for h in GRID for _ in GRID_SHIFTS] + shifts = [s for _ in GRID for s in GRID_SHIFTS] + assert_shift_invariants(households, shifts, year) + + +def test_unemployment_excluded_in_2020_counts_toward_student_loan_magi(): + # 2020: section 85(c) excludes up to $10,200 of unemployment compensation + # from gross income; section 221(b)(2)(C)(i) disregards that exclusion. + household = {**GRID[0], "wages": 60_000, "unemployment_compensation": 10_000} + results = calculate([household], 2020) + assert results["tax_unit_taxable_unemployment_compensation"][0] == 0 + assert results["student_loan_interest_ald_magi"][0] == pytest.approx(70_000) + assert_differential([household], results) + + +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(st.tuples(household_strategy, shift_strategy), min_size=1, max_size=15), + st.sampled_from(YEARS), +) +def test_random_households_keep_the_invariants(cases, year): + households = [h for h, _ in cases] + shifts = [s for _, s in cases] + assert_shift_invariants(households, shifts, year) From 361ccac6e1d7ccd6d0332d32bf167d04079c2c47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 17:04:04 -0400 Subject: [PATCH 5/8] Leave the NIIT modified AGI to #9808 PolicyEngine/policyengine-us#9808 adds the section 1411(d) add-back with a separate, overridable section 911(a)(1) amount, which is more precise than adding the whole foreign_earned_income_exclusion input. Restore niit_magi and its tests to main and drop the NIIT assertion from the property test. Co-Authored-By: Claude Opus 5.5 --- .../fix-student-loan-magi-931-911.fixed.md | 2 +- .../gov/irs/tax/federal_income/niit_magi.yaml | 12 ---------- .../tests/test_modified_agi_add_backs.py | 12 +--------- .../gov/irs/tax/federal_income/niit_magi.py | 24 ++++--------------- 4 files changed, 7 insertions(+), 43 deletions(-) diff --git a/changelog.d/fix-student-loan-magi-931-911.fixed.md b/changelog.d/fix-student-loan-magi-931-911.fixed.md index b15b6919377..57cf716d1fe 100644 --- a/changelog.d/fix-student-loan-magi-931-911.fixed.md +++ b/changelog.d/fix-student-loan-magi-931-911.fixed.md @@ -1 +1 @@ -Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security and the net investment income tax add back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. +Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security adds back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. 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 f43b831dc93..cf878ddd3ff 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,15 +70,3 @@ output: adjusted_gross_income: 160_000 niit_magi: 160_000 - -- name: Income excluded under section 911 is added back - period: 2024 - input: - adjusted_gross_income: 190_000 - foreign_earned_income_exclusion: 20_000 - net_investment_income: 30_000 - filing_status: SINGLE - output: - # 26 U.S.C. 1411(d): modified AGI 210,000; tax 3.8% x (210,000 - 200,000). - niit_magi: 210_000 - net_investment_income_tax: 380 diff --git a/policyengine_us/tests/test_modified_agi_add_backs.py b/policyengine_us/tests/test_modified_agi_add_backs.py index 1816d22f634..cd7a4d5569e 100644 --- a/policyengine_us/tests/test_modified_agi_add_backs.py +++ b/policyengine_us/tests/test_modified_agi_add_backs.py @@ -10,8 +10,7 @@ gross income increased by any amount excluded under sections 911, 931 or 933; the model computes that once, as `agi_plus_section_911_931_933_exclusions`. Section 86(b)(2) (taxable Social -Security) and section 1411(d) (net investment income tax) add back the -section 911 exclusion too. +Security) adds back all three as well. In the model, income inputs are net of the section 911 exclusion, and sections 931 and 933 are above-the-line deductions. A household with @@ -75,7 +74,6 @@ "additional_senior_deduction_magi", "tax_unit_taxable_unemployment_compensation", "taxable_ss_magi", - "niit_magi", *NON_DECREASING, *NON_INCREASING, ] @@ -259,14 +257,6 @@ def assert_shift_invariants(households, shifts, year): assert m[v] >= b[v] - slack * max(1, abs(b[v])), (v, context) for v in NON_INCREASING: assert m[v] <= b[v] + slack * max(1, abs(b[v])), (v, context) - # Section 1411(d) adds back only section 911. - niit_rise = m["niit_magi"] - b["niit_magi"] - expected_niit_rise = agi_change + ( - amount if category == "foreign_earned_income_exclusion" else 0 - ) - assert niit_rise == pytest.approx( - expected_niit_rise, abs=tolerance(m["niit_magi"]) - ), context household_strategy = st.fixed_dictionaries( 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 392849ade9f..bf52b431407 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 @@ -8,15 +8,10 @@ class niit_magi(Variable): unit = USD documentation = ( "Form 8960 line 13 modified adjusted gross income: adjusted gross " - "income plus the section 911 foreign earned income exclusion and 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. " - "Section 1411(d) adds back only the section 911(a)(1) exclusion, net " - "of deductions disallowed under section 911(d)(6) (Line 13 MAGI " - "Worksheet, line 2c); the model's single foreign_earned_income_exclusion " - "input also holds the housing exclusion and housing deduction, so a " - "filer who claims those adds back more than Form 8960 does. The CFC and PFIC " - "adjustments of Treas. Reg. 1.1411-10(e) are not modeled." + "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." ) definition_period = YEAR reference = ( @@ -32,13 +27,4 @@ def formula(tax_unit, period, parameters): estate_magi_adjustment = tax_unit.sum( not_dependent * person("estate_income_niit_magi_adjustment", period) ) - # 26 U.S.C. 1411(d): income inputs are net of the section 911 - # exclusion, so add it back. - foreign_earned_income_exclusion = tax_unit( - "foreign_earned_income_exclusion", period - ) - return ( - tax_unit("adjusted_gross_income", period) - + foreign_earned_income_exclusion - + estate_magi_adjustment - ) + return tax_unit("adjusted_gross_income", period) + estate_magi_adjustment From d6176bd4782237b724e1fd138d5f9193ec637155 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 05:48:18 -0400 Subject: [PATCH 6/8] Address review r1: Form 2555 amounts, clean vehicle prior year, IRMAA 135 - Modified AGIs add back section 911 in full: Form 2555 lines 45 and 50 (Schedule 8812 line 2b, Schedule 1-A lines 2b and 2c, Pub. 970 Worksheet 4-1, Form 8936 lines 1c and 1d, Form 8962 Worksheet 1-1 line 3). New input section_911_excluded_income carries that amount; it defaults to foreign_earned_income_exclusion, which stays the section 911(f) stacking amount net of disallowed deductions. The shared modified AGI, student-loan MAGI, Social Security MAGI, IRMAA MAGI and the Medicaid/ACA modification list use it. - Clean vehicles: 30D(f)(10) and 25E(b) allow the credit when the lesser of this year's and the preceding year's modified AGI is within the limit. New inputs clean_vehicle_credit_prior_year_magi (defaults to this year's modified AGI) and clean_vehicle_credit_prior_year_filing_status (defaults to this year's status); each year is tested against its own status's limit (26 CFR 1.30D-4(b)(3), 1.25E-1(c)(3)). - IRMAA keeps adding back the section 135 exclusion wherever it is recorded, because AGI deducts it there: a bond in a child's name does not qualify (135(c)(1)(B), Form 8815), so an amount on a dependent is the filer's. A filer-only add-back would leave the dependent-recorded deduction in AGI and give 99,000 for 100,000 of wages. - Tests: the reviewer's reproductions as YAML cases, Hypothesis invariants for the gross and net section 911 amounts, the lesser-of test and the IRMAA identity on provided and computed AGI. Co-Authored-By: Claude Opus 5.5 --- .../fix-student-loan-magi-931-911.fixed.md | 2 +- .../gov/hhs/medicaid/income/modification.yaml | 5 +- .../gov/aca/eligibility/aca_magi.yaml | 12 + .../medicare_irmaa_magi_two_years_prior.yaml | 115 ++++++ ...hicle_credit_prior_year_filing_status.yaml | 14 + .../clean_vehicle_credit_prior_year_magi.yaml | 15 + .../new_clean_vehicle_credit_eligible.yaml | 61 +++ .../used_clean_vehicle_credit_eligible.yaml | 39 ++ .../credits/ctc/phase_out/ctc_phase_out.yaml | 15 + ...i_plus_section_911_931_933_exclusions.yaml | 12 + .../social_security/taxable_ss_magi.yaml | 24 ++ .../section_911_excluded_income.yaml | 39 ++ .../student_loan_interest_ald.yaml | 19 + .../student_loan_interest_ald_magi.yaml | 20 + .../tests/test_modified_agi_add_backs.py | 349 +++++++++++++++++- .../medicare_irmaa_magi_two_years_prior.py | 20 +- ...vehicle_credit_prior_year_filing_status.py | 28 ++ .../clean_vehicle_credit_prior_year_magi.py | 26 ++ .../new/new_clean_vehicle_credit_eligible.py | 16 +- .../used_clean_vehicle_credit_eligible.py | 20 +- .../foreign_earned_income.py | 2 +- .../section_911_excluded_income.py | 32 ++ .../student_loan_interest_ald_magi.py | 9 +- ...agi_plus_section_911_931_933_exclusions.py | 10 +- .../social_security/taxable_ss_magi.py | 5 +- 25 files changed, 875 insertions(+), 34 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml create mode 100644 policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.py create mode 100644 policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py diff --git a/changelog.d/fix-student-loan-magi-931-911.fixed.md b/changelog.d/fix-student-loan-magi-931-911.fixed.md index 57cf716d1fe..dbdafa37cec 100644 --- a/changelog.d/fix-student-loan-magi-931-911.fixed.md +++ b/changelog.d/fix-student-loan-magi-931-911.fixed.md @@ -1 +1 @@ -Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security adds back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. +Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security adds back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. Modified AGIs, including the Medicaid and ACA modified AGI, add back the full section 911 amount (Form 2555 lines 45 and 50, new input `section_911_excluded_income`), not the smaller amount the section 911(f) rate stacking uses. The clean vehicle credits accept the preceding year's modified AGI and filing status (`clean_vehicle_credit_prior_year_magi`, `clean_vehicle_credit_prior_year_filing_status`) and are allowed when either year's modified AGI is within the limit. diff --git a/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml b/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml index 73c84e45aab..24c48d75830 100644 --- a/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml +++ b/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml @@ -1,7 +1,8 @@ description: Income sources not included in AGI but included in Medicaid/CHIP/ACA-related MAGI. values: 2010-01-01: - - foreign_earned_income_exclusion + # Form 2555 lines 45 and 50 (Form 8962 Worksheet 1-1 line 3). + - section_911_excluded_income - tax_exempt_interest_income - tax_exempt_social_security metadata: @@ -12,3 +13,5 @@ metadata: href: https://www.law.cornell.edu/uscode/text/26/36B#d_2 - title: Modified Adjusted Gross Income (MAGI) href: https://www.healthcare.gov/glossary/modified-adjusted-gross-income-magi + - title: 2024 Instructions for Form 8962, Worksheet 1-1, line 3 + href: https://www.irs.gov/pub/irs-prior/i8962--2024.pdf#page=8 diff --git a/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml b/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml index b3a02c0c364..1a5d780bf1d 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml @@ -29,3 +29,15 @@ is_aca_ptc_eligible: [false, true, true] tax_unit_medicaid_income_level: 3.37 aca_magi_fraction: 3.48 + +- name: Section 911 adds back Form 2555 lines 45 and 50, before disallowed deductions + period: 2025 + input: + adjusted_gross_income: 40_000 + section_911_excluded_income: 25_000 + # The rate stacking amount after 10,000 of disallowed deductions. + foreign_earned_income_exclusion: 15_000 + output: + # Form 8962 Worksheet 1-1 line 3 takes Form 2555 lines 45 and 50. + medicaid_magi: 65_000 + aca_magi: 65_000 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index a23513bb44b..0335356acbf 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -109,3 +109,118 @@ 2014: 4_000 output: medicare_irmaa_magi_two_years_prior: 115_000 + +- name: Section 911 adds back Form 2555 lines 45 and 50, before disallowed deductions. + period: 2026 + input: + age: 66 + adjusted_gross_income: + 2024: 100_000 + section_911_excluded_income: + 2024: 25_000 + # The rate stacking amount after 10,000 of disallowed deductions. + foreign_earned_income_exclusion: + 2024: 15_000 + output: + medicare_irmaa_magi_two_years_prior: 125_000 + +- name: Form 2555 amounts provided for an unmodeled lag year are added back in full. + period: 2016 + input: + age: 66 + adjusted_gross_income: + 2014: 100_000 + section_911_excluded_income: + 2014: 25_000 + foreign_earned_income_exclusion: + 2014: 15_000 + output: + medicare_irmaa_magi_two_years_prior: 125_000 + +- name: A section 135 exclusion recorded on a dependent is added back, as AGI deducts it. + period: 2026 + input: + people: + parent: + age: + 2024: 64 + 2026: 66 + employment_income: + 2024: 100_000 + 2026: 0 + child: + age: + 2024: 13 + 2026: 15 + # The parent's bond, cashed for the child's tuition: a bond issued + # in the name of a child under 24 does not qualify (Form 8815). + us_bonds_for_higher_ed: + 2024: 1_000 + 2026: 0 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # 2024 AGI 99,000 deducts the 1,000; IRMAA MAGI is AGI without it. + medicare_irmaa_magi_two_years_prior: 100_000 + +- name: Every member's section 135 exclusion is added back to a provided AGI. + period: 2026 + input: + people: + head: + age: 66 + us_bonds_for_higher_ed: + 2024: 1_000 + spouse: + age: 64 + us_bonds_for_higher_ed: + 2024: 2_000 + child: + age: 15 + us_bonds_for_higher_ed: + 2024: 4_000 + tax_units: + tax_unit: + members: [head, spouse, child] + adjusted_gross_income: + 2024: 100_000 + marital_units: + marital_unit: + members: [head, spouse] + child_marital_unit: + members: [child] + households: + household: + members: [head, spouse, child] + output: + # The 4,000 recorded on the child is the couple's exclusion too. + medicare_irmaa_magi_two_years_prior: 107_000 + +- name: Every member's section 135 exclusion for an unmodeled lag year is added back. + period: 2016 + input: + people: + parent: + age: 66 + us_bonds_for_higher_ed: + 2014: 1_000 + 2016: 0 + child: + age: 15 + us_bonds_for_higher_ed: + 2014: 4_000 + 2016: 0 + tax_units: + tax_unit: + members: [parent, child] + adjusted_gross_income: + 2014: 100_000 + households: + household: + members: [parent, child] + output: + medicare_irmaa_magi_two_years_prior: 105_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.yaml new file mode 100644 index 00000000000..78fb5eb49a3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.yaml @@ -0,0 +1,14 @@ +- name: Defaults to this year's filing status. + period: 2024 + input: + filing_status: HEAD_OF_HOUSEHOLD + output: + clean_vehicle_credit_prior_year_filing_status: HEAD_OF_HOUSEHOLD + +- name: A provided filing status overrides the default. + period: 2024 + input: + filing_status: SINGLE + clean_vehicle_credit_prior_year_filing_status: JOINT + output: + clean_vehicle_credit_prior_year_filing_status: JOINT diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.yaml new file mode 100644 index 00000000000..6d36e6c267d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.yaml @@ -0,0 +1,15 @@ +- name: Defaults to this year's modified AGI. + period: 2024 + input: + adjusted_gross_income: 140_000 + foreign_earned_income_exclusion: 20_000 + output: + clean_vehicle_credit_prior_year_magi: 160_000 + +- name: A provided amount overrides the default. + period: 2024 + input: + adjusted_gross_income: 140_000 + clean_vehicle_credit_prior_year_magi: 130_000 + output: + clean_vehicle_credit_prior_year_magi: 130_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml index 29ca2fa0099..2c8a1ad4f69 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.yaml @@ -81,3 +81,64 @@ output: # 26 U.S.C. 30D(f)(10)(C): modified AGI 160,000 is over the 150,000 limit. new_clean_vehicle_credit_eligible: false + +- name: The preceding year's modified AGI qualifies when this year's, with the section 911 add-back, does not. + period: 2024 + input: + purchased_qualifying_new_clean_vehicle: true + new_clean_vehicle_battery_capacity: 4 + new_clean_vehicle_classification: VAN + new_clean_vehicle_msrp: 50_000 + adjusted_gross_income: 140_000 + foreign_earned_income_exclusion: 20_000 + # Form 8936 line 4: 2023 modified AGI. + clean_vehicle_credit_prior_year_magi: 130_000 + output: + # 26 U.S.C. 30D(f)(10)(A): the lesser of 160,000 and 130,000 is within + # the 150,000 limit. + new_clean_vehicle_credit_eligible: true + +- name: Neither year's modified AGI qualifies. + period: 2024 + input: + purchased_qualifying_new_clean_vehicle: true + new_clean_vehicle_battery_capacity: 4 + new_clean_vehicle_classification: VAN + new_clean_vehicle_msrp: 50_000 + adjusted_gross_income: 140_000 + foreign_earned_income_exclusion: 20_000 + clean_vehicle_credit_prior_year_magi: 150_001 + output: + new_clean_vehicle_credit_eligible: false + +- name: After a change in filing status, the preceding year's modified AGI is tested against that year's limit. + period: 2024 + input: + purchased_qualifying_new_clean_vehicle: true + new_clean_vehicle_battery_capacity: 4 + new_clean_vehicle_classification: VAN + new_clean_vehicle_msrp: 50_000 + filing_status: SINGLE + adjusted_gross_income: 200_000 + clean_vehicle_credit_prior_year_magi: 250_000 + clean_vehicle_credit_prior_year_filing_status: JOINT + output: + # 26 CFR 1.30D-4(b)(3): 250,000 is within the 300,000 joint limit that + # applied to the 2023 return, though over this year's 150,000 limit. + new_clean_vehicle_credit_eligible: true + +- name: After a change in filing status, the lesser modified AGI is not tested against the other year's limit. + period: 2024 + input: + purchased_qualifying_new_clean_vehicle: true + new_clean_vehicle_battery_capacity: 4 + new_clean_vehicle_classification: VAN + new_clean_vehicle_msrp: 50_000 + filing_status: JOINT + adjusted_gross_income: 350_000 + clean_vehicle_credit_prior_year_magi: 200_000 + clean_vehicle_credit_prior_year_filing_status: SINGLE + output: + # 26 CFR 1.30D-4(b)(3): 350,000 is over the 300,000 joint limit, and + # 200,000 over the 150,000 single limit of the 2023 return. + new_clean_vehicle_credit_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml index 0d7f1d3d233..c5cab62ba7b 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.yaml @@ -73,3 +73,42 @@ output: # 26 U.S.C. 25E(b)(3): modified AGI 80,000 is over the 75,000 limit. used_clean_vehicle_credit_eligible: false + +- name: The preceding year's modified AGI qualifies when this year's, with the section 911 add-back, does not. + period: 2024 + input: + purchased_qualifying_used_clean_vehicle: true + used_clean_vehicle_sale_price: 20_000 + adjusted_gross_income: 70_000 + foreign_earned_income_exclusion: 10_000 + # Form 8936 line 4: 2023 modified AGI. + clean_vehicle_credit_prior_year_magi: 60_000 + output: + # 26 U.S.C. 25E(b)(1): the lesser of 80,000 and 60,000 is within the + # 75,000 limit. + used_clean_vehicle_credit_eligible: true + +- name: Neither year's modified AGI qualifies. + period: 2024 + input: + purchased_qualifying_used_clean_vehicle: true + used_clean_vehicle_sale_price: 20_000 + adjusted_gross_income: 70_000 + foreign_earned_income_exclusion: 10_000 + clean_vehicle_credit_prior_year_magi: 75_001 + output: + used_clean_vehicle_credit_eligible: false + +- name: After a change in filing status, the preceding year's modified AGI is tested against that year's limit. + period: 2024 + input: + purchased_qualifying_used_clean_vehicle: true + used_clean_vehicle_sale_price: 20_000 + filing_status: SINGLE + adjusted_gross_income: 100_000 + clean_vehicle_credit_prior_year_magi: 140_000 + clean_vehicle_credit_prior_year_filing_status: JOINT + output: + # 26 CFR 1.25E-1(c)(3): 140,000 is within the 150,000 joint limit that + # applied to the 2023 return. + used_clean_vehicle_credit_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml index d7a747e73f0..0184964cf32 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/phase_out/ctc_phase_out.yaml @@ -25,3 +25,18 @@ output: # 26 U.S.C. 24(b)(1): modified AGI 405,000; 5 increments of $1,000 x $50. ctc_phase_out: 250 + +- name: The phase-out adds back Form 2555 lines 45 and 50 in full, not the stacking amount. + period: 2024 + input: + adjusted_gross_income: 180_000 + # Form 2555 lines 45 and 50: 25,000. The Foreign Earned Income Tax + # Worksheet subtracts 10,000 of disallowed itemized deductions (line 2b), + # leaving 15,000 (line 2c) for the rate stacking. + section_911_excluded_income: 25_000 + foreign_earned_income_exclusion: 15_000 + ctc_phase_out_threshold: 200_000 # Single. + output: + # Schedule 8812 line 2b: modified AGI 180,000 + 25,000 = 205,000; + # 5 increments of $1,000 x $50. + ctc_phase_out: 250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml index 47d3a93caa6..7f9615d3537 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.yaml @@ -32,3 +32,15 @@ # 933 are above-the-line deductions. AGI 100,000 - 30,000 = 70,000. adjusted_gross_income: 70_000 agi_plus_section_911_931_933_exclusions: 115_000 + +- name: Section 911 adds back Form 2555 lines 45 and 50, before disallowed deductions + period: 2024 + input: + adjusted_gross_income: 180_000 + section_911_excluded_income: 25_000 + # Line 2c of the Foreign Earned Income Tax Worksheet: 25,000 less 10,000 + # of disallowed deductions. + foreign_earned_income_exclusion: 15_000 + output: + # Schedule 1-A lines 2b and 2c take Form 2555 lines 45 and 50. + agi_plus_section_911_931_933_exclusions: 205_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml index ec429eb8aeb..608001ab7f5 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.yaml @@ -46,3 +46,27 @@ taxable_ss_magi: 30_000 tax_unit_taxable_social_security: 9_600 adjusted_gross_income: 29_600 + +- name: Section 911 adds back Form 2555 lines 45 and 50, before disallowed deductions + period: 2024 + input: + people: + person1: + age: 67 + irs_employment_income: 20_000 + social_security_retirement: 20_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + section_911_excluded_income: 10_000 + # The rate stacking amount after 4,000 of disallowed deductions. + foreign_earned_income_exclusion: 6_000 + households: + household: + members: [person1] + state_code: TX + output: + # As in the first case: 20,000 of wages plus Form 2555 lines 45 and 50. + taxable_ss_magi: 30_000 + tax_unit_taxable_social_security: 9_600 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml new file mode 100644 index 00000000000..65038ebf808 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/section_911_excluded_income.yaml @@ -0,0 +1,39 @@ +- name: Defaults to the stacking amount when no deductions are disallowed + period: 2024 + input: + foreign_earned_income_exclusion: 15_000 + output: + section_911_excluded_income: 15_000 + +- name: A provided Form 2555 amount overrides the default + period: 2024 + input: + foreign_earned_income_exclusion: 15_000 + section_911_excluded_income: 25_000 + output: + section_911_excluded_income: 25_000 + +- name: The rate stacking keeps the amount net of disallowed deductions + period: 2024 + input: + people: + person1: + age: 40 + irs_employment_income: 100_000 + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # Form 2555 lines 45 and 50: 25,000; worksheet line 2c: 15,000. + section_911_excluded_income: 25_000 + foreign_earned_income_exclusion: 15_000 + households: + household: + members: [person1] + state_code: TX + output: + adjusted_gross_income: 100_000 + agi_plus_section_911_931_933_exclusions: 125_000 + # Foreign Earned Income Tax Worksheet line 3: taxable income 85,400 + # (100,000 less the 14,600 standard deduction) plus line 2c, 15,000. + taxable_income_plus_section_911_exclusion: 100_400 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml index 69a1db0ecd2..717160aaf00 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald.yaml @@ -240,3 +240,22 @@ output: # AGI 160,000; MAGI 190,000: reduced by (190,000 - 165,000) / 30,000. student_loan_interest_ald: [416.67, 0] + +- name: Disallowed deductions do not reduce the section 911 add-back + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + student_loan_interest: 2_500 + irs_employment_income: 70_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + section_911_excluded_income: 15_000 + foreign_earned_income_exclusion: 5_000 + output: + # MAGI 70,000 + 15,000 = 85,000; reduced by (85,000 - 80,000) / 15,000. + student_loan_interest_ald: 1_666.67 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml index f2745241135..a9e4ef9b198 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/student_loan_interest/student_loan_interest_ald_magi.yaml @@ -230,3 +230,23 @@ output: adjusted_gross_income: 57_000 student_loan_interest_ald_magi: 60_000 + +- name: Section 911 adds back Form 2555 lines 45 and 50, before disallowed deductions + period: 2024 + absolute_error_margin: 0.1 + input: + people: + person1: + irs_employment_income: 70_000 + student_loan_interest_ald_eligible: true + tax_units: + tax_unit: + members: [person1] + filing_status: SINGLE + # Form 2555 lines 45 and 50: 15,000. The rate stacking amount is + # 5,000 after 10,000 of disallowed deductions. + section_911_excluded_income: 15_000 + foreign_earned_income_exclusion: 5_000 + output: + # Pub. 970 Worksheet 4-1 lines 5 and 6 take Form 2555 lines 45 and 50. + student_loan_interest_ald_magi: 85_000 diff --git a/policyengine_us/tests/test_modified_agi_add_backs.py b/policyengine_us/tests/test_modified_agi_add_backs.py index cd7a4d5569e..5ef33caf734 100644 --- a/policyengine_us/tests/test_modified_agi_add_backs.py +++ b/policyengine_us/tests/test_modified_agi_add_backs.py @@ -15,7 +15,11 @@ In the model, income inputs are net of the section 911 exclusion, and sections 931 and 933 are above-the-line deductions. A household with possession or Puerto Rico income therefore reports it in wages and deducts -it again. +it again. The section 911 amount comes in two sizes. Modified AGIs add back +Form 2555 lines 45 and 50 (`section_911_excluded_income`). The section +911(f) rate stacking uses that amount less deductions disallowed because +they relate to the excluded income (`foreign_earned_income_exclusion`, +Foreign Earned Income Tax Worksheet line 2c). Random households set both. Two kinds of test, on random households: @@ -24,15 +28,31 @@ gross income less every deduction. On every return, MAGI must equal AGI plus the student loan interest deduction, the three excluded amounts, and any unemployment compensation section 85(c) excluded (2020). The shared - modified AGI must equal AGI plus the three excluded amounts. + modified AGI must equal AGI plus the three excluded amounts, and the + Medicaid and ACA modified AGI must equal AGI plus the section 911 amount; + each takes Form 2555 lines 45 and 50 in full. The rate stacking adds the + amount net of disallowed deductions to taxable income. A clean vehicle + credit is income-eligible exactly when the lesser of this year's and the + preceding year's modified AGI is within the limit. - Shifting income into an excluded category while every other part of AGI - stays fixed (raising the section 911 exclusion, or raising wages together - with the section 931 or 933 deduction) raises student-loan MAGI by exactly + stays fixed (raising the section 911 exclusion, raising the deductions it + disallows, or raising wages together with the section 931 or 933 + deduction) raises student-loan MAGI by exactly the amount shifted, never raises the student loan interest deduction, and moves every phase-out the shared modified AGI feeds in one direction only: no reduction falls and no deduction, cap or eligibility rises. AGI itself moves only by the fall in the student loan interest deduction and, in 2020, by any unemployment compensation that fall makes taxable. + +A third test checks the Medicare IRMAA modified AGI of 42 U.S.C. +1395r(i)(4), from income two years before the benefit year. With AGI +provided, it equals AGI plus tax-exempt interest, every member's section 135 +exclusion, and the section 911 (Form 2555 lines 45 and 50), 931 and 933 +amounts. AGI deducts the section 135 exclusion wherever it is recorded in +the tax unit: only a bond issued to an owner aged 24 or older qualifies +(section 135(c)(1)(B)), so an amount recorded on a dependent is the filer's +exclusion for the dependent's tuition. With AGI computed from wages, the +IRMAA MAGI therefore does not depend on section 135 amounts at all. """ import numpy as np @@ -45,8 +65,12 @@ st = pytest.importorskip("hypothesis.strategies") STATUSES = ["SINGLE", "JOINT", "HEAD_OF_HOUSEHOLD"] +# Excluded-income categories a household can shift income into. The section +# 911 amount added back is Form 2555 lines 45 and 50: the rate stacking +# amount plus the deductions disallowed because they relate to it. EXCLUSIONS = [ "foreign_earned_income_exclusion", + "section_911_disallowed_deductions", "specified_possession_income", "puerto_rico_income", ] @@ -71,6 +95,9 @@ TAX_UNIT_OUTPUTS = [ "adjusted_gross_income", "agi_plus_section_911_931_933_exclusions", + "medicaid_magi", + "taxable_income", + "taxable_income_plus_section_911_exclusion", "additional_senior_deduction_magi", "tax_unit_taxable_unemployment_compensation", "taxable_ss_magi", @@ -85,7 +112,29 @@ ] +# An input given for any tax unit in a simulation is set for all of them; the +# others take the variable's default value, not its formula. So a batch +# supplies each defaulted input for every tax unit or for none. +def sets_section_911_amount(households): + """Whether the batch supplies the Form 2555 amount. + + It must when any household has disallowed deductions. Otherwise the + batch may leave it to default to the stacking amount. + """ + return any( + h["section_911_disallowed_deductions"] or h["set_section_911_amount"] + for h in households + ) + + +# Over every income limit: with this as the preceding year's modified AGI, +# this year's alone decides, as when the input is not provided. +NO_PRIOR_YEAR_MAGI = 10**9 + + def build_situation(households, year): + set_911 = sets_section_911_amount(households) + set_prior = any(h["prior_year_magi"] is not None for h in households) people, tax_units, marital_units = {}, {}, {} groups = {"households": {}, "spm_units": {}, "families": {}} for i, h in enumerate(households): @@ -137,6 +186,16 @@ def build_situation(households, year): "new_clean_vehicle_msrp": {year: 40_000}, "purchased_qualifying_used_clean_vehicle": {year: h["bought_vehicle"]}, } + if set_911: + # Otherwise the Form 2555 amount defaults to the stacking amount. + tax_units[f"tax_unit_{i}"]["section_911_excluded_income"] = { + year: section_911_excluded_income(h) + } + if set_prior: + prior = h["prior_year_magi"] + tax_units[f"tax_unit_{i}"]["clean_vehicle_credit_prior_year_magi"] = { + year: NO_PRIOR_YEAR_MAGI if prior is None else prior + } groups["households"][f"household_{i}"] = { "members": members, "state_code": {year: "TX"}, @@ -152,6 +211,12 @@ def build_situation(households, year): } +def section_911_excluded_income(h): + # Form 2555 lines 45 and 50 (Foreign Earned Income Tax Worksheet line + # 2a), from line 2c and the disallowed deductions on line 2b. + return h["foreign_earned_income_exclusion"] + h["section_911_disallowed_deductions"] + + def calculate(households, year): simulation = Simulation(situation=build_situation(households, year)) results = { @@ -162,15 +227,29 @@ def calculate(households, year): results[v] = np.asarray( simulation.calculate(v, year, map_to="tax_unit"), dtype=float ) + # Each household's clean vehicle income limits and whether the credits + # are in effect, to check the lesser-of test. + p = simulation.tax_benefit_system.parameters(year).gov.irs.credits.clean_vehicle + for credit, eligibility in [ + ("new", p.new.eligibility), + ("used", p.used.eligibility), + ]: + results[f"{credit}_income_limit"] = np.array( + [eligibility.income_limit[h["status"]] for h in households], dtype=float + ) + results[f"{credit}_in_effect"] = np.full( + len(households), float(eligibility.in_effect) + ) return results def shifted(household, category, amount): """The same household with `amount` more income in an excluded category. - The section 911 exclusion is not in the income inputs, so raising it - leaves AGI alone. Section 931 and 933 income is in wages and deducted - again, so raising it raises both and leaves AGI alone. + The section 911 exclusion is not in the income inputs, so raising it, or + the deductions it disallows, leaves AGI alone. Section 931 and 933 + income is in wages and deducted again, so raising it raises both and + leaves AGI alone. """ return {**household, category: household[category] + amount} @@ -205,6 +284,36 @@ def assert_differential(households, results): r["additional_senior_deduction_magi"] == r["agi_plus_section_911_931_933_exclusions"] ), (h, r) + # Medicaid and ACA modified AGI adds back only section 911 (and + # tax-exempt interest and Social Security, which these households + # lack), also as Form 2555 lines 45 and 50. + section_911 = section_911_excluded_income(h) + assert r["medicaid_magi"] == pytest.approx( + max(0, r["adjusted_gross_income"] + section_911), + abs=tolerance(r["adjusted_gross_income"], section_911), + ), (h, r) + # The rate stacking adds the amount net of disallowed deductions. + stacked = r["taxable_income_plus_section_911_exclusion"] - r["taxable_income"] + assert stacked == pytest.approx( + h["foreign_earned_income_exclusion"], + abs=tolerance(r["taxable_income_plus_section_911_exclusion"]), + ), (h, r) + # Clean vehicles: income-eligible when the lesser of this year's and + # the preceding year's modified AGI is within the limit. Without a + # preceding-year amount, this year's decides. + magi = r["agi_plus_section_911_931_933_exclusions"] + prior = magi if h["prior_year_magi"] is None else h["prior_year_magi"] + for credit in ["new", "used"]: + expected = ( + h["bought_vehicle"] + and r[f"{credit}_in_effect"] == 1 + and min(magi, prior) <= r[f"{credit}_income_limit"] + ) + assert r[f"{credit}_clean_vehicle_credit_eligible"] == expected, ( + credit, + h, + r, + ) # The deduction is capped at the interest paid and $2,500. assert ( 0 @@ -269,6 +378,12 @@ def assert_shift_invariants(households, shifts, year): "foreign_earned_income_exclusion": st.one_of( st.just(0), st.integers(1, 130_000) ), + "section_911_disallowed_deductions": st.one_of( + st.just(0), st.integers(1, 40_000) + ), + # Whether to supply the Form 2555 amount when no deductions are + # disallowed, or leave it to default to the stacking amount. + "set_section_911_amount": st.booleans(), "specified_possession_income": st.one_of(st.just(0), st.integers(1, 100_000)), "puerto_rico_income": st.one_of(st.just(0), st.integers(1, 100_000)), "tips": st.one_of(st.just(0), st.integers(1, 30_000)), @@ -276,6 +391,7 @@ def assert_shift_invariants(households, shifts, year): "car_loan_interest": st.one_of(st.just(0), st.integers(1, 12_000)), "real_estate_taxes": st.one_of(st.just(0), st.integers(1, 60_000)), "bought_vehicle": st.booleans(), + "prior_year_magi": st.one_of(st.none(), st.integers(0, 400_000)), } ) shift_strategy = st.tuples(st.sampled_from(EXCLUSIONS), st.integers(1, 100_000)) @@ -290,6 +406,8 @@ def assert_shift_invariants(households, shifts, year): unemployment_compensation=0, student_loan_interest=2_500, foreign_earned_income_exclusion=0, + section_911_disallowed_deductions=0, + set_section_911_amount=False, specified_possession_income=0, puerto_rico_income=0, tips=10_000, @@ -297,6 +415,7 @@ def assert_shift_invariants(households, shifts, year): car_loan_interest=5_000, real_estate_taxes=50_000, bought_vehicle=True, + prior_year_magi=None, ), # Joint, with unemployment compensation and all three exclusions. dict( @@ -306,6 +425,8 @@ def assert_shift_invariants(households, shifts, year): unemployment_compensation=8_000, student_loan_interest=3_000, foreign_earned_income_exclusion=5_000, + section_911_disallowed_deductions=2_000, + set_section_911_amount=True, specified_possession_income=4_000, puerto_rico_income=3_000, tips=0, @@ -313,6 +434,9 @@ def assert_shift_invariants(households, shifts, year): car_loan_interest=9_000, real_estate_taxes=20_000, bought_vehicle=True, + # Under the $150,000 joint limit for used vehicles; this year's + # modified AGI is over it. + prior_year_magi=140_000, ), # Head of household near the tip, overtime and car loan phase-outs. dict( @@ -322,6 +446,8 @@ def assert_shift_invariants(households, shifts, year): unemployment_compensation=0, student_loan_interest=1_000, foreign_earned_income_exclusion=0, + section_911_disallowed_deductions=0, + set_section_911_amount=False, specified_possession_income=0, puerto_rico_income=0, tips=20_000, @@ -329,6 +455,7 @@ def assert_shift_invariants(households, shifts, year): car_loan_interest=10_000, real_estate_taxes=0, bought_vehicle=False, + prior_year_magi=None, ), # High income, near the SALT cap phase-down and the CTC phase-out. dict( @@ -338,13 +465,18 @@ def assert_shift_invariants(households, shifts, year): unemployment_compensation=0, student_loan_interest=0, foreign_earned_income_exclusion=0, + section_911_disallowed_deductions=0, + set_section_911_amount=False, specified_possession_income=0, puerto_rico_income=0, tips=0, overtime=0, car_loan_interest=0, real_estate_taxes=60_000, - bought_vehicle=False, + bought_vehicle=True, + # Under the $150,000 single limit for new vehicles, over the $75,000 + # used-vehicle limit; this year's modified AGI is over both. + prior_year_magi=140_000, ), ] GRID_SHIFTS = [(category, 15_000) for category in EXCLUSIONS] @@ -386,3 +518,204 @@ def test_random_households_keep_the_invariants(cases, year): households = [h for h, _ in cases] shifts = [s for _, s in cases] assert_shift_invariants(households, shifts, year) + + +# Medicare IRMAA: households whose income two years before the benefit year +# is either provided as AGI or computed from wages. 2016 looks back to 2014, +# before the model's first year, where only provided amounts count. +IRMAA_YEARS = [2016, 2017, 2026] +# Benefit years whose lag year the model computes. +IRMAA_COMPUTED_YEARS = [2017, 2026] + +irmaa_household_strategy = st.fixed_dictionaries( + { + "joint": st.booleans(), + "has_dependent": st.booleans(), + "agi": st.integers(0, 600_000), + "wages": st.integers(0, 400_000), + "spouse_wages": st.one_of(st.just(0), st.integers(1, 200_000)), + "tax_exempt_interest": st.one_of(st.just(0), st.integers(1, 20_000)), + "head_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), + "spouse_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), + "dependent_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), + "foreign_earned_income_exclusion": st.one_of( + st.just(0), st.integers(1, 130_000) + ), + "section_911_disallowed_deductions": st.one_of( + st.just(0), st.integers(1, 40_000) + ), + "set_section_911_amount": st.booleans(), + "specified_possession_income": st.one_of(st.just(0), st.integers(1, 50_000)), + "puerto_rico_income": st.one_of(st.just(0), st.integers(1, 50_000)), + } +) + + +def build_irmaa_situation(households, year, computed): + """Households for an IRMAA benefit year. + + With `computed`, the lag year's AGI comes from wages, which include any + possession and Puerto Rico income (deducted again above the line), and + ages are set for both years so the lag year's roles match. Otherwise AGI + is provided for the lag year and ages for the benefit year only. + """ + lag = year - 2 + set_911 = sets_section_911_amount(households) + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + + def person(age, bonds, wages=0, **extra): + # An input carries forward to later years it is not supplied for, so + # the benefit year's amounts are set to zero. + p = { + "age": {lag: age - 2, year: age} if computed else {year: age}, + "us_bonds_for_higher_ed": {lag: bonds, year: 0}, + **extra, + } + if computed: + p["employment_income"] = {lag: wages, year: 0} + return p + + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = person( + 66, + h["head_bonds"], + h["wages"] + h["specified_possession_income"] + h["puerto_rico_income"], + tax_exempt_interest_income={lag: h["tax_exempt_interest"], year: 0}, + ) + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["joint"]: + spouse = f"spouse_{i}" + people[spouse] = person(64, h["spouse_bonds"], h["spouse_wages"]) + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["has_dependent"]: + child = f"child_{i}" + people[child] = person(15, h["dependent_bonds"]) + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + "foreign_earned_income_exclusion": { + lag: h["foreign_earned_income_exclusion"] + }, + "specified_possession_income": {lag: h["specified_possession_income"]}, + "puerto_rico_income": {lag: h["puerto_rico_income"]}, + } + if not computed: + tax_units[f"tax_unit_{i}"]["adjusted_gross_income"] = {lag: h["agi"]} + if set_911: + tax_units[f"tax_unit_{i}"]["section_911_excluded_income"] = { + lag: section_911_excluded_income(h) + } + for group in groups: + groups[group][f"{group}_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def assert_irmaa_identity(households, year, computed): + simulation = Simulation(situation=build_irmaa_situation(households, year, computed)) + magi = simulation.calculate("medicare_irmaa_magi_two_years_prior", year) + for i, h in enumerate(households): + # 42 U.S.C. 1395r(i)(4)(A): AGI without regard to sections 135, 911, + # 931 and 933, plus tax-exempt interest. AGI deducts the section 135 + # exclusion wherever it is recorded in the tax unit, and the IRMAA + # MAGI adds back the same amount. + bonds = ( + h["head_bonds"] + + (h["spouse_bonds"] if h["joint"] else 0) + + (h["dependent_bonds"] if h["has_dependent"] else 0) + ) + if computed: + # Wages include the possession and Puerto Rico income, which is + # deducted again; AGI is the rest of the wages less the section + # 135 exclusions, so the IRMAA MAGI does not depend on them. + agi_without_135 = h["wages"] + (h["spouse_wages"] if h["joint"] else 0) + else: + agi_without_135 = h["agi"] + bonds + expected = ( + agi_without_135 + + h["tax_exempt_interest"] + + section_911_excluded_income(h) + + h["specified_possession_income"] + + h["puerto_rico_income"] + ) + assert magi[i] == pytest.approx(expected, abs=tolerance(expected)), ( + h, + year, + computed, + magi[i], + ) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(irmaa_household_strategy, min_size=1, max_size=15), + st.sampled_from(IRMAA_YEARS), +) +def test_irmaa_magi_adds_back_every_exclusion_to_a_provided_agi(households, year): + assert_irmaa_identity(households, year, computed=False) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(irmaa_household_strategy, min_size=1, max_size=15), + st.sampled_from(IRMAA_COMPUTED_YEARS), +) +def test_irmaa_magi_from_computed_agi_ignores_section_135(households, year): + assert_irmaa_identity(households, year, computed=True) + + +# A parent with the section 135 exclusion recorded on a dependent student, and +# a joint couple with every exclusion and the Form 2555 amount above the +# stacking amount. +IRMAA_GRID = [ + dict( + joint=False, + has_dependent=True, + agi=99_000, + wages=100_000, + spouse_wages=0, + tax_exempt_interest=0, + head_bonds=0, + spouse_bonds=0, + dependent_bonds=1_000, + foreign_earned_income_exclusion=0, + section_911_disallowed_deductions=0, + set_section_911_amount=False, + specified_possession_income=0, + puerto_rico_income=0, + ), + dict( + joint=True, + has_dependent=True, + agi=150_000, + wages=120_000, + spouse_wages=30_000, + tax_exempt_interest=2_000, + head_bonds=1_000, + spouse_bonds=2_000, + dependent_bonds=4_000, + foreign_earned_income_exclusion=15_000, + section_911_disallowed_deductions=10_000, + set_section_911_amount=True, + specified_possession_income=3_000, + puerto_rico_income=4_000, + ), +] + + +@pytest.mark.parametrize( + "year, computed", + [(year, False) for year in IRMAA_YEARS] + + [(year, True) for year in IRMAA_COMPUTED_YEARS], +) +def test_irmaa_grid_keeps_the_identity(year, computed): + assert_irmaa_identity(IRMAA_GRID, year, computed) diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index a89a561f28f..9620d524041 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -4,13 +4,18 @@ # 42 U.S.C. 1395r(i)(4)(A): adjusted gross income (i) determined without # regard to sections 135, 911, 931 and 933 of the Internal Revenue Code and # (ii) increased by tax-exempt interest. Income inputs are net of the section -# 911 exclusion; sections 135, 931 and 933 are above-the-line deductions in -# this model. Adding each amount back undoes it. +# 911 amounts (Form 2555 lines 45 and 50); sections 135, 931 and 933 are +# above-the-line deductions in this model. Adding each amount back undoes it. TAX_UNIT_ADDITIONS = [ - "foreign_earned_income_exclusion", + "section_911_excluded_income", "specified_possession_income", "puerto_rico_income", ] +# Summed over every member, as adjusted gross income deducts them. Only a +# bond issued to an owner aged 24 or older qualifies for the section 135 +# exclusion (26 U.S.C. 135(c)(1)(B)), so a bond in a child's name does not +# (Form 8815); an amount recorded on a dependent is the filer's exclusion for +# the dependent's tuition, and adjusted gross income deducts it. PERSON_ADDITIONS = ["tax_exempt_interest_income", "us_bonds_for_higher_ed"] @@ -20,7 +25,11 @@ class medicare_irmaa_magi_two_years_prior(Variable): label = "Medicare IRMAA MAGI from two years prior" unit = USD definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/42/1395r#i_4" + reference = ( + "https://www.law.cornell.edu/uscode/text/42/1395r#i_4", + "https://www.law.cornell.edu/uscode/text/26/135#c_1", + "https://www.irs.gov/pub/irs-prior/f8815--2025.pdf#page=3", + ) documentation = ( "Modified adjusted gross income used to determine Medicare IRMAA " "charges. Callers may provide this value directly for the current " @@ -52,6 +61,9 @@ def formula(tax_unit, period, parameters): def provided(variable, population): holder = simulation.get_holder(variable) value = holder.get_array(prior_period, simulation.branch_name) + if value is None and variable == "section_911_excluded_income": + # Defaults to the stacking amount, as its formula does. + return provided("foreign_earned_income_exclusion", population) return population.empty_array() if value is None else value magi = provided("adjusted_gross_income", tax_unit) diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.py new file mode 100644 index 00000000000..481df087fc6 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_filing_status.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, +) + + +class clean_vehicle_credit_prior_year_filing_status(Variable): + value_type = Enum + entity = TaxUnit + possible_values = FilingStatus + default_value = FilingStatus.SINGLE + definition_period = YEAR + label = "Filing status for the year before a clean vehicle purchase" + documentation = ( + "Filing status on the preceding year's return (Form 8936 line 5). " + "The preceding year's modified AGI is compared with the income limit " + "for this filing status. When not provided, it defaults to the " + "current year's filing status." + ) + reference = ( + # Special rule for change in filing status. + "https://www.ecfr.gov/current/title-26/part-1/section-1.30D-4#p-1.30D-4(b)(3)", + "https://www.ecfr.gov/current/title-26/part-1/section-1.25E-1#p-1.25E-1(c)(3)", + "https://www.irs.gov/pub/irs-prior/f8936--2024.pdf#page=1", + ) + + def formula(tax_unit, period, parameters): + return tax_unit("filing_status", period) diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.py new file mode 100644 index 00000000000..8581242a911 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/clean_vehicle_credit_prior_year_magi.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class clean_vehicle_credit_prior_year_magi(Variable): + value_type = float + entity = TaxUnit + label = "Modified AGI for the year before a clean vehicle purchase" + unit = USD + documentation = ( + "Modified adjusted gross income for the preceding taxable year, as " + "the new and used clean vehicle credits define it: adjusted gross " + "income plus income excluded under 26 U.S.C. 911, 931 and 933 (Form " + "8936 line 4). A filer meets the credits' income limit if either " + "year's modified AGI does. Household and microsimulation inputs " + "usually cover a single year, so reading the preceding year would " + "return zero income and pass everyone; when not provided, this " + "defaults to the current year's modified AGI, and the current year " + "alone decides." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/30D#f_10_A", + "https://www.law.cornell.edu/uscode/text/26/25E#b_1", + "https://www.irs.gov/pub/irs-prior/f8936--2024.pdf#page=1", + ) + adds = ["agi_plus_section_911_931_933_exclusions"] diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py index 90d0cde9b9e..e2b8c06810f 100644 --- a/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/new/new_clean_vehicle_credit_eligible.py @@ -24,11 +24,21 @@ def formula(tax_unit, period, parameters): # Capacity limit applies with and without the Inflation Reduction Act. capacity = tax_unit("new_clean_vehicle_battery_capacity", period) meets_capacity_requirement = capacity >= p.min_kwh - # 26 U.S.C. 30D(f)(10)(C): modified adjusted gross income adds back - # income excluded under sections 911, 931 and 933. + # 26 U.S.C. 30D(f)(10): no credit if the lesser of this year's and + # the preceding year's modified adjusted gross income exceeds the + # limit. Modified AGI adds back income excluded under sections 911, + # 931 and 933 (30D(f)(10)(C)). If the filing status changed, each + # year's modified AGI is compared with the limit for that year's + # status (26 CFR 1.30D-4(b)(3)); otherwise this is the lesser-of test. magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) + prior_magi = tax_unit("clean_vehicle_credit_prior_year_magi", period) filing_status = tax_unit("filing_status", period) - meets_income_limit = magi <= p.income_limit[filing_status] + prior_filing_status = tax_unit( + "clean_vehicle_credit_prior_year_filing_status", period + ) + meets_income_limit = (magi <= p.income_limit[filing_status]) | ( + prior_magi <= p.income_limit[prior_filing_status] + ) msrp = tax_unit("new_clean_vehicle_msrp", period) classification = tax_unit("new_clean_vehicle_classification", period) meets_msrp_limit = msrp <= p.msrp_limit[classification] diff --git a/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py b/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py index a5ad6523fea..5779fa2e9db 100644 --- a/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/clean_vehicle/used/used_clean_vehicle_credit_eligible.py @@ -19,14 +19,22 @@ def formula(tax_unit, period, parameters): # of this intra-year cutoff. if not p.eligibility.in_effect: return False - # Income eligibility based on lesser of MAGI in current and prior year. - # Assume current-year MAGI for now. 26 U.S.C. 25E(b)(3): modified - # adjusted gross income adds back income excluded under sections - # 911, 931 and 933. + # 26 U.S.C. 25E(b)(1): no credit if the lesser of this year's and + # the preceding year's modified adjusted gross income exceeds the + # limit. Modified AGI adds back income excluded under sections 911, + # 931 and 933 (25E(b)(3)). If the filing status changed, each year's + # modified AGI is compared with the limit for that year's status + # (26 CFR 1.25E-1(c)(3)); otherwise this is the lesser-of test. magi = tax_unit("agi_plus_section_911_931_933_exclusions", period) + prior_magi = tax_unit("clean_vehicle_credit_prior_year_magi", period) filing_status = tax_unit("filing_status", period) - income_limit = p.eligibility.income_limit[filing_status] - income_eligible = magi <= income_limit + prior_filing_status = tax_unit( + "clean_vehicle_credit_prior_year_filing_status", period + ) + income_limit = p.eligibility.income_limit + income_eligible = (magi <= income_limit[filing_status]) | ( + prior_magi <= income_limit[prior_filing_status] + ) # Purchase price limit. sale_price = tax_unit("used_clean_vehicle_sale_price", period) price_eligible = sale_price <= p.eligibility.sale_price_limit 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..ecf8cdee2c6 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). Modified adjusted gross incomes add back section_911_excluded_income, the Form 2555 amounts before that subtraction." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911", 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 new file mode 100644 index 00000000000..c7fecc900da --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class section_911_excluded_income(Variable): + value_type = float + entity = TaxUnit + label = "Income excluded or deducted under section 911" + unit = USD + documentation = ( + "Foreign earned income and housing amounts excluded from gross income " + "under 26 U.S.C. 911, plus the section 911(c)(4) foreign housing " + "deduction: Form 2555 lines 45 and 50 (both spouses' forms on a joint " + "return). Modified adjusted gross incomes add this amount back. It is " + "larger than foreign_earned_income_exclusion, the amount the section " + "911(f) rate stacking uses, by any deductions disallowed because they " + "relate to the excluded income. When not provided, it equals " + "foreign_earned_income_exclusion, which is exact when no deductions " + "are disallowed." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/911", + # Schedule 8812 line 2b, Schedule 1-A lines 2b and 2c, Form 8936 + # lines 1c and 1d: the modified AGI add-back is Form 2555 lines 45 + # and 50. + "https://www.irs.gov/pub/irs-prior/f1040s8--2025.pdf#page=1", + "https://www.irs.gov/pub/irs-prior/f1040s1a--2025.pdf#page=1", + # The Foreign Earned Income Tax Worksheet subtracts the disallowed + # deductions from the same amount (lines 2a to 2c). + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + ) + adds = ["foreign_earned_income_exclusion"] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py index 4e8b67c9667..603d061f89e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/student_loan_interest/student_loan_interest_ald_magi.py @@ -46,15 +46,16 @@ def formula(person, period, parameters): set(p_irs.ald.deductions) - set(p.person_alds) - set(p.excluded_alds) ) ald_sum_taxunit = add(person.tax_unit, period, other_alds) - # Income inputs are net of the section 911 exclusion; add it back. - foreign_earned_income_exclusion = person.tax_unit( - "foreign_earned_income_exclusion", period + # Income inputs are net of the section 911 amounts; add them back in + # full (Form 2555 lines 45 and 50; Pub. 970 Worksheet 4-1 lines 5-6). + section_911_excluded_income = person.tax_unit( + "section_911_excluded_income", period ) filing_status = person.tax_unit("filing_status", period) joint = filing_status == filing_status.possible_values.JOINT frac = where(joint, 0.5, 1.0) taxunit_adjustment_shared = ( - not_dependent * (foreign_earned_income_exclusion - ald_sum_taxunit) * frac + not_dependent * (section_911_excluded_income - ald_sum_taxunit) * frac ) modified_adjusted_gross_income = ( total_gross_income - ald_sum_person + taxunit_adjustment_shared diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py index 9bbfbc42a4c..6e0b29006be 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/agi_plus_section_911_931_933_exclusions.py @@ -14,9 +14,11 @@ class agi_plus_section_911_931_933_exclusions(Variable): "adjusted gross income of the child tax credit, the education " "credits, the saver's credit, the clean vehicle credits, the SALT " "cap phase-down and the senior, tip, overtime and car loan interest " - "deductions. Income inputs are net of the section 911 exclusion, so " - "it is added back; sections 931 and 933 are above-the-line " - "deductions in this model, so adding them back reverses them." + "deductions. Income inputs are net of the section 911 amounts, so " + "they are added back in full (Form 2555 lines 45 and 50, before the " + "disallowed deductions the section 911(f) rate stacking subtracts); " + "sections 931 and 933 are above-the-line deductions in this model, " + "so adding them back reverses them." ) definition_period = YEAR reference = ( @@ -33,7 +35,7 @@ class agi_plus_section_911_931_933_exclusions(Variable): ) adds = [ "adjusted_gross_income", - "foreign_earned_income_exclusion", + "section_911_excluded_income", "specified_possession_income", "puerto_rico_income", ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py index 72653e24bc5..b2c8a2954c3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/taxable_ss_magi.py @@ -36,8 +36,9 @@ def formula(tax_unit, period, parameters): gross_income += not_dependent * max_(0, add(person, period, [source])) gross_income = tax_unit.sum(gross_income) # IRC 86(b)(2)(A): determined without regard to section 911. Income - # inputs are net of the foreign earned income exclusion; add it back. - gross_income += tax_unit("foreign_earned_income_exclusion", period) + # inputs are net of the section 911 amounts; add them back in full + # (Form 2555 lines 45 and 50). + gross_income += tax_unit("section_911_excluded_income", period) above_the_line_deductions = irs.ald.deductions revoked_deductions = ss_magi.revoked_deductions deductions = [ From ae35fea93b1ecb5bacaa51e0c747342f83390c7e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 09:09:15 -0400 Subject: [PATCH 7/8] Sum IRMAA add-backs over the members AGI counts 42 U.S.C. 1395r(i)(4) reads the beneficiary's own return. After #9801, adjusted gross income sums person-level deductions over the head and spouse, plus every member for gov.irs.ald.filer_amounts_recorded_on_ dependents. The IRMAA MAGI now sums its person-level add-backs with the same rule (tax_unit_non_dep_add): - Tax-exempt interest counts for the head and spouse only. A dependent's interest is on the dependent's own return, like the rest of the dependent's income; main summed it over every member. - The section 135 exclusion counts for every member, because #9801 lists it: a bond in a child's name does not qualify (135(c)(1)(B), Form 8815), so an amount recorded on a dependent is the parent's exclusion, and AGI deducts it. Head and spouse only would give 99,000 for a parent with 100,000 of wages and 1,000 recorded on the child. - The 2015-2016 fallback applies the same rule with the benefit year's roles, reading stored values only. Tests: YAML cases for a dependent's tax-exempt interest in a modeled and an unmodeled lag year; the Hypothesis IRMAA identity now draws spouse and dependent tax-exempt interest and sets lag-year ages in both paths (age defaults to 40, which would make a child a second adult). Co-Authored-By: Claude Opus 5.5 --- .../fix-student-loan-magi-931-911.fixed.md | 2 +- .../medicare_irmaa_magi_two_years_prior.yaml | 60 +++++++++++++++++- .../tests/test_modified_agi_add_backs.py | 62 +++++++++++++------ .../medicare_irmaa_magi_two_years_prior.py | 41 ++++++++---- 4 files changed, 132 insertions(+), 33 deletions(-) diff --git a/changelog.d/fix-student-loan-magi-931-911.fixed.md b/changelog.d/fix-student-loan-magi-931-911.fixed.md index dbdafa37cec..8c2dce08e27 100644 --- a/changelog.d/fix-student-loan-magi-931-911.fixed.md +++ b/changelog.d/fix-student-loan-magi-931-911.fixed.md @@ -1 +1 @@ -Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security adds back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933. Modified AGIs, including the Medicaid and ACA modified AGI, add back the full section 911 amount (Form 2555 lines 45 and 50, new input `section_911_excluded_income`), not the smaller amount the section 911(f) rate stacking uses. The clean vehicle credits accept the preceding year's modified AGI and filing status (`clean_vehicle_credit_prior_year_magi`, `clean_vehicle_credit_prior_year_filing_status`) and are allowed when either year's modified AGI is within the limit. +Add back income excluded under 26 U.S.C. 911, 931 and 933 where a modified AGI requires it: the student loan interest deduction (which now also counts all unemployment compensation, because section 221(b)(2)(C) disregards only the section 85(c) exclusion), the child tax credit, the education credits, the saver's credit, the clean vehicle credits, the SALT cap phase-down, and the tip, overtime and car loan interest deductions. Taxable Social Security adds back the section 911 exclusion, and Medicare IRMAA adds back sections 135, 911, 931 and 933, summing person-level amounts over the members adjusted gross income counts: a dependent's own tax-exempt interest no longer counts, while a section 135 exclusion recorded on a dependent, which adjusted gross income deducts, is added back. Modified AGIs, including the Medicaid and ACA modified AGI, add back the full section 911 amount (Form 2555 lines 45 and 50, new input `section_911_excluded_income`), not the smaller amount the section 911(f) rate stacking uses. The clean vehicle credits accept the preceding year's modified AGI and filing status (`clean_vehicle_credit_prior_year_magi`, `clean_vehicle_credit_prior_year_filing_status`) and are allowed when either year's modified AGI is within the limit. diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml index 0335356acbf..4fb58ad3f70 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.yaml @@ -197,9 +197,67 @@ household: members: [head, spouse, child] output: - # The 4,000 recorded on the child is the couple's exclusion too. + # The 4,000 recorded on the child is the couple's exclusion too + # (gov.irs.ald.filer_amounts_recorded_on_dependents). medicare_irmaa_magi_two_years_prior: 107_000 +- name: A dependent's tax-exempt interest is on the dependent's own return. + period: 2026 + input: + people: + parent: + age: + 2024: 64 + 2026: 66 + employment_income: + 2024: 100_000 + 2026: 0 + tax_exempt_interest_income: + 2024: 3_000 + 2026: 0 + child: + age: + 2024: 13 + 2026: 15 + tax_exempt_interest_income: + 2024: 2_000 + 2026: 0 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # 2024 AGI 100,000 leaves out the child's income; only the parent's + # 3,000 of tax-exempt interest is added. + medicare_irmaa_magi_two_years_prior: 103_000 + +- name: A dependent's tax-exempt interest provided for an unmodeled lag year is not added. + period: 2016 + input: + people: + parent: + age: 66 + tax_exempt_interest_income: + 2014: 3_000 + 2016: 0 + child: + age: 15 + tax_exempt_interest_income: + 2014: 2_000 + 2016: 0 + tax_units: + tax_unit: + members: [parent, child] + adjusted_gross_income: + 2014: 100_000 + households: + household: + members: [parent, child] + output: + medicare_irmaa_magi_two_years_prior: 103_000 + - name: Every member's section 135 exclusion for an unmodeled lag year is added back. period: 2016 input: diff --git a/policyengine_us/tests/test_modified_agi_add_backs.py b/policyengine_us/tests/test_modified_agi_add_backs.py index 5ef33caf734..b169f5c3d8a 100644 --- a/policyengine_us/tests/test_modified_agi_add_backs.py +++ b/policyengine_us/tests/test_modified_agi_add_backs.py @@ -46,13 +46,16 @@ A third test checks the Medicare IRMAA modified AGI of 42 U.S.C. 1395r(i)(4), from income two years before the benefit year. With AGI -provided, it equals AGI plus tax-exempt interest, every member's section 135 -exclusion, and the section 911 (Form 2555 lines 45 and 50), 931 and 933 -amounts. AGI deducts the section 135 exclusion wherever it is recorded in -the tax unit: only a bond issued to an owner aged 24 or older qualifies -(section 135(c)(1)(B)), so an amount recorded on a dependent is the filer's -exclusion for the dependent's tuition. With AGI computed from wages, the -IRMAA MAGI therefore does not depend on section 135 amounts at all. +provided, it equals AGI plus the head's and spouse's tax-exempt interest, +every member's section 135 exclusion, and the section 911 (Form 2555 lines +45 and 50), 931 and 933 amounts. A dependent's own tax-exempt interest is on +the dependent's return, as the rest of the dependent's income is. AGI +deducts the section 135 exclusion wherever it is recorded in the tax unit +(`gov.irs.ald.filer_amounts_recorded_on_dependents`): only a bond issued to +an owner aged 24 or older qualifies (section 135(c)(1)(B)), so an amount +recorded on a dependent is the filer's exclusion for the dependent's +tuition. With AGI computed from wages, the IRMAA MAGI therefore does not +depend on section 135 amounts at all. """ import numpy as np @@ -535,6 +538,8 @@ def test_random_households_keep_the_invariants(cases, year): "wages": st.integers(0, 400_000), "spouse_wages": st.one_of(st.just(0), st.integers(1, 200_000)), "tax_exempt_interest": st.one_of(st.just(0), st.integers(1, 20_000)), + "spouse_tax_exempt_interest": st.one_of(st.just(0), st.integers(1, 20_000)), + "dependent_tax_exempt_interest": st.one_of(st.just(0), st.integers(1, 20_000)), "head_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), "spouse_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), "dependent_bonds": st.one_of(st.just(0), st.integers(1, 10_000)), @@ -555,22 +560,23 @@ def build_irmaa_situation(households, year, computed): """Households for an IRMAA benefit year. With `computed`, the lag year's AGI comes from wages, which include any - possession and Puerto Rico income (deducted again above the line), and - ages are set for both years so the lag year's roles match. Otherwise AGI - is provided for the lag year and ages for the benefit year only. + possession and Puerto Rico income (deducted again above the line). + Otherwise AGI is provided for the lag year. Ages are set for both years, + so the lag year's tax unit roles (which decide whose tax-exempt interest + is on the return) are the benefit year's; age defaults to 40 otherwise. """ lag = year - 2 set_911 = sets_section_911_amount(households) people, tax_units, marital_units = {}, {}, {} groups = {"households": {}, "spm_units": {}, "families": {}} - def person(age, bonds, wages=0, **extra): + def person(age, bonds, interest, wages=0): # An input carries forward to later years it is not supplied for, so # the benefit year's amounts are set to zero. p = { - "age": {lag: age - 2, year: age} if computed else {year: age}, + "age": {lag: age - 2, year: age}, "us_bonds_for_higher_ed": {lag: bonds, year: 0}, - **extra, + "tax_exempt_interest_income": {lag: interest, year: 0}, } if computed: p["employment_income"] = {lag: wages, year: 0} @@ -581,19 +587,26 @@ def person(age, bonds, wages=0, **extra): people[head] = person( 66, h["head_bonds"], + h["tax_exempt_interest"], h["wages"] + h["specified_possession_income"] + h["puerto_rico_income"], - tax_exempt_interest_income={lag: h["tax_exempt_interest"], year: 0}, ) members = [head] marital_units[f"marital_unit_{i}"] = {"members": [head]} if h["joint"]: spouse = f"spouse_{i}" - people[spouse] = person(64, h["spouse_bonds"], h["spouse_wages"]) + people[spouse] = person( + 64, + h["spouse_bonds"], + h["spouse_tax_exempt_interest"], + h["spouse_wages"], + ) members.append(spouse) marital_units[f"marital_unit_{i}"]["members"].append(spouse) if h["has_dependent"]: child = f"child_{i}" - people[child] = person(15, h["dependent_bonds"]) + people[child] = person( + 15, h["dependent_bonds"], h["dependent_tax_exempt_interest"] + ) members.append(child) marital_units[f"marital_unit_{i}_child"] = {"members": [child]} tax_units[f"tax_unit_{i}"] = { @@ -625,9 +638,13 @@ def assert_irmaa_identity(households, year, computed): magi = simulation.calculate("medicare_irmaa_magi_two_years_prior", year) for i, h in enumerate(households): # 42 U.S.C. 1395r(i)(4)(A): AGI without regard to sections 135, 911, - # 931 and 933, plus tax-exempt interest. AGI deducts the section 135 - # exclusion wherever it is recorded in the tax unit, and the IRMAA - # MAGI adds back the same amount. + # 931 and 933, plus tax-exempt interest on the return. AGI deducts the + # section 135 exclusion wherever it is recorded in the tax unit, and + # the IRMAA MAGI adds back the same amount. A dependent's tax-exempt + # interest, like the rest of the dependent's income, is not on it. + interest = h["tax_exempt_interest"] + ( + h["spouse_tax_exempt_interest"] if h["joint"] else 0 + ) bonds = ( h["head_bonds"] + (h["spouse_bonds"] if h["joint"] else 0) @@ -642,7 +659,7 @@ def assert_irmaa_identity(households, year, computed): agi_without_135 = h["agi"] + bonds expected = ( agi_without_135 - + h["tax_exempt_interest"] + + interest + section_911_excluded_income(h) + h["specified_possession_income"] + h["puerto_rico_income"] @@ -684,6 +701,8 @@ def test_irmaa_magi_from_computed_agi_ignores_section_135(households, year): wages=100_000, spouse_wages=0, tax_exempt_interest=0, + spouse_tax_exempt_interest=0, + dependent_tax_exempt_interest=0, head_bonds=0, spouse_bonds=0, dependent_bonds=1_000, @@ -700,6 +719,9 @@ def test_irmaa_magi_from_computed_agi_ignores_section_135(households, year): wages=120_000, spouse_wages=30_000, tax_exempt_interest=2_000, + spouse_tax_exempt_interest=1_500, + # The child's own; not on the parents' return. + dependent_tax_exempt_interest=2_500, head_bonds=1_000, spouse_bonds=2_000, dependent_bonds=4_000, diff --git a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py index 9620d524041..0507bc3d442 100644 --- a/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py +++ b/policyengine_us/variables/gov/hhs/medicare/eligibility/medicare_irmaa_magi_two_years_prior.py @@ -11,11 +11,14 @@ "specified_possession_income", "puerto_rico_income", ] -# Summed over every member, as adjusted gross income deducts them. Only a -# bond issued to an owner aged 24 or older qualifies for the section 135 -# exclusion (26 U.S.C. 135(c)(1)(B)), so a bond in a child's name does not -# (Form 8815); an amount recorded on a dependent is the filer's exclusion for -# the dependent's tuition, and adjusted gross income deducts it. +# Person-level amounts on the filer's return, summed over the members whose +# amounts adjusted gross income counts: the head and spouse, and every member +# for amounts listed in gov.irs.ald.filer_amounts_recorded_on_dependents. A +# dependent's own tax-exempt interest is on the dependent's return. The +# section 135 exclusion is listed: only a bond issued to an owner aged 24 or +# older qualifies (26 U.S.C. 135(c)(1)(B); Form 8815), so an amount recorded +# on a dependent is the filer's exclusion for the dependent's tuition, which +# adjusted gross income deducts and this adds back. PERSON_ADDITIONS = ["tax_exempt_interest_income", "us_bonds_for_higher_ed"] @@ -34,9 +37,9 @@ class medicare_irmaa_magi_two_years_prior(Variable): "Modified adjusted gross income used to determine Medicare IRMAA " "charges. Callers may provide this value directly for the current " "benefit year. When it is not provided, PolicyEngine computes it from " - "two years prior as adjusted gross income plus tax-exempt interest " - "and the amounts excluded or deducted under IRC sections 135, 911, " - "931 and 933. " + "two years prior as adjusted gross income plus the head's and " + "spouse's tax-exempt interest and the amounts excluded or deducted " + "under IRC sections 135, 911, 931 and 933. " "Single-year datasets without lagged income inputs therefore default " "to the modeled prior-year values, which may be zero. When the year " "two years prior precedes the first year PolicyEngine models (2015), " @@ -46,17 +49,30 @@ class medicare_irmaa_magi_two_years_prior(Variable): def formula(tax_unit, period, parameters): prior_period = period.offset(-2, "year") if prior_period.start.year >= FIRST_MODELED_YEAR: + filer_amounts = parameters( + prior_period + ).gov.irs.ald.filer_amounts_recorded_on_dependents return add( tax_unit, prior_period, - ["adjusted_gross_income", *TAX_UNIT_ADDITIONS, *PERSON_ADDITIONS], + ["adjusted_gross_income", *TAX_UNIT_ADDITIONS], + ) + tax_unit_non_dep_add( + tax_unit, + prior_period, + PERSON_ADDITIONS, + include_dependents=filer_amounts, ) # Benefit years 2015 and 2016 look back to 2013 and 2014, before the # parameters begin, so income for those years cannot be computed. Use # income provided for them and treat the rest as zero, the value the # formula yields in later years when no prior-year income is provided. - # Reading stored values computes and caches nothing for those years. + # Reading stored values computes and caches nothing for those years; + # tax unit roles are the benefit year's. simulation = tax_unit.simulation + filer_amounts = parameters( + period + ).gov.irs.ald.filer_amounts_recorded_on_dependents + dependent = tax_unit.members("is_tax_unit_dependent", period) def provided(variable, population): holder = simulation.get_holder(variable) @@ -70,5 +86,8 @@ def provided(variable, population): for variable in TAX_UNIT_ADDITIONS: magi = magi + provided(variable, tax_unit) for variable in PERSON_ADDITIONS: - magi = magi + tax_unit.sum(provided(variable, tax_unit.members)) + amount = provided(variable, tax_unit.members) + if variable not in filer_amounts: + amount = where(dependent, 0, amount) + magi = magi + tax_unit.sum(amount) return magi From 5669235456ee479c1d994f85f70c2830a899559a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 07:57:49 -0400 Subject: [PATCH 8/8] Account for SALT phaseout in section 911 interaction properties Section 164(b)(7)(B)(iv) and the 2025 Schedule A worksheet add Form 2555 lines 45 and 50 to SALT MAGI. An exclusion can reduce itemized deductions even when the choice to itemize holds, so fixed taxable income was an invalid premise. Preserve equality for election changes and unchanged deductions, assert nondecreasing taxable income for same-choice exclusion pairs, and reuse the grid simulation for a hand-calculated SALT regression and independent form comparisons. Production formulas are unchanged. --- .../test_form_4952_section_911_interaction.py | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/test_form_4952_section_911_interaction.py b/policyengine_us/tests/test_form_4952_section_911_interaction.py index 912637a8972..2397a971768 100644 --- a/policyengine_us/tests/test_form_4952_section_911_interaction.py +++ b/policyengine_us/tests/test_form_4952_section_911_interaction.py @@ -48,8 +48,10 @@ test_form_4952_election_worksheet.py), whatever the other households in the batch elect or exclude. With no capital gain excess, every amount that section 911(f) replaces equals its original, bit for bit. -4. Monotonicity. E and X change taxable income only through the choice to - itemize (see assert_monotone). As E rises, net_capital_gain and dwks10 +4. Monotonicity. E changes taxable income only through the choice to + itemize (see assert_monotone). X can also reduce the SALT deduction + through its modified AGI add-back, raising taxable income even while + the choice holds. As E rises, net_capital_gain and dwks10 never rise, and net capital gain falls by at most the extra amount elected. While the choice to itemize holds, the section 911 net capital gain and qualified dividends and both capital gain excesses never rise @@ -214,6 +216,10 @@ def rate(schedule, x): "alternative_minimum_tax", "income_tax_before_credits", "tax_unit_itemizes", + "itemized_taxable_income_deductions", + "agi_plus_section_911_931_933_exclusions", + "salt_cap", + "salt_deduction", "income_tax", ] @@ -644,8 +650,11 @@ def split_variants(results, count): def assert_monotone(households, groups): """Property 4. - For these households, the election and the exclusion reach taxable - income only through the choice to itemize. The model itemizes when that gives the lower + For these households, the election reaches taxable income only through + the choice to itemize. The exclusion can also reduce the SALT deduction: + section 164(b)(7)(B)(iv) adds section 911 income back to SALT MAGI (2025 + Schedule A instructions, page 7, worksheet lines 3b and 3c). + The model itemizes when that gives the lower income_tax (tax_unit_itemizes), and an AMT filer can lower the tax by itemizing deductions smaller than the standard deduction, which reduce alternative minimum taxable income. An election or exclusion that raises @@ -663,6 +672,11 @@ def assert_monotone(households, groups): itemizes = law["tax_unit_itemizes"] for name in VARIANTS: same = groups[name]["tax_unit_itemizes"] == itemizes + if name in ("more_excluded", "unstacked"): + same &= ~itemizes | ( + groups[name]["itemized_taxable_income_deductions"] + == law["itemized_taxable_income_deductions"] + ) assert np.array_equal( groups[name]["taxable_income"][same], taxable_income[same] ), name @@ -690,6 +704,12 @@ def assert_monotone(households, groups): def same_choice(lower, higher): return lower["tax_unit_itemizes"] == higher["tax_unit_itemizes"] + # More excluded cannot lower taxable income while the itemization + # choice holds, including when the SALT MAGI add-back reduces deductions. + for lower, higher in exclusion_pairs: + rows = same_choice(lower, higher) + assert (higher["taxable_income"] >= lower["taxable_income"])[rows].all() + # More elected: the gains never rise, net capital gain falls by at most # the extra amount elected, and amounts limited by taxable income or # Form 6251 line 6 never rise while the choice to itemize holds. @@ -834,7 +854,23 @@ def grid(): return rows -GRID = grid() +# 2025 Schedule A instructions, page 7, State and Local Tax Deduction +# Worksheet lines 2 to 10: AGI of $480,000 plus $100,000 from Form 2555 +# raises MAGI to $580,000. The $40,000 SALT cap falls by 30% of $80,000 +# to $16,000. Both returns itemize, but taxable income rises by $24,000. +# https://www.irs.gov/pub/irs-prior/i1040sca--2025.pdf#page=7 +SALT_PHASE_OUT_CASES = [ + household( + wages=450_000, + long_term=30_000, + election=10_000, + real_estate_taxes=60_000, + exclusion=x, + ) + for x in (0, 100_000) +] + +GRID = grid() + SALT_PHASE_OUT_CASES def test_grid(): @@ -863,6 +899,17 @@ def test_grid(): assert (groups["more_elected"]["regular_tax"] > law["regular_tax"] + 1).any() assert (law["regular_tax"] > groups["unstacked"]["regular_tax"] + 1).any() + # The SALT cases also exercise the variants and independent form + # comparisons above, reusing the grid's simulation. + salt = {v: values[-len(SALT_PHASE_OUT_CASES) :] for v, values in law.items()} + assert salt["tax_unit_itemizes"].all() + assert salt["agi_plus_section_911_931_933_exclusions"] == pytest.approx( + [480_000, 580_000] + ) + assert salt["salt_cap"] == pytest.approx([40_000, 16_000]) + assert salt["salt_deduction"] == pytest.approx([40_000, 16_000]) + assert salt["taxable_income"] == pytest.approx([440_000, 464_000]) + def test_election_can_lower_the_tax_where_15_percent_exceeds_12(): """An intended exception to "electing more never lowers the tax".