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..8c2dce08e27 --- /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 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/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/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 58269f0372b..db209769b00 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/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 f0064f8cdb8..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 @@ -73,3 +73,212 @@ 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 + +- 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 + # (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: + 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 22bb0f02292..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 @@ -68,3 +68,77 @@ 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 + +- 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 37dd6276173..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 @@ -63,3 +63,52 @@ 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 + +- 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/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..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 @@ -13,3 +13,30 @@ 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 + +- 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/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..7f9615d3537 --- /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,46 @@ +- 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 + +- 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 new file mode 100644 index 00000000000..608001ab7f5 --- /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,72 @@ +- 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 + +- 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/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/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..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 @@ -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,114 @@ # (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] + +- 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 271f816df7b..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 @@ -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,153 @@ 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 + +- 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/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/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". 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..b169f5c3d8a --- /dev/null +++ b/policyengine_us/tests/test_modified_agi_add_backs.py @@ -0,0 +1,743 @@ +"""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) 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 +possession or Puerto Rico income therefore reports it in wages and deducts +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: + +- 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, 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, 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 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 +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"] +# 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", +] +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", + "medicaid_magi", + "taxable_income", + "taxable_income_plus_section_911_exclusion", + "additional_senior_deduction_magi", + "tax_unit_taxable_unemployment_compensation", + "taxable_ss_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", +] + + +# 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): + 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"]}, + } + 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"}, + "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 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 = { + 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 + ) + # 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, 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} + + +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) + # 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 + <= 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) + + +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) + ), + "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)), + "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(), + "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)) + +# 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, + section_911_disallowed_deductions=0, + set_section_911_amount=False, + 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, + prior_year_magi=None, + ), + # 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, + section_911_disallowed_deductions=2_000, + set_section_911_amount=True, + 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, + # 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( + status="HEAD_OF_HOUSEHOLD", + wages=140_000, + spouse_wages=0, + 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, + overtime=10_000, + 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( + status="SINGLE", + wages=480_000, + spouse_wages=0, + 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=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] + + +@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) + + +# 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)), + "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)), + "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). + 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, 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}, + "us_bonds_for_higher_ed": {lag: bonds, year: 0}, + "tax_exempt_interest_income": {lag: interest, year: 0}, + } + 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["tax_exempt_interest"], + h["wages"] + h["specified_possession_income"] + h["puerto_rico_income"], + ) + 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_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"], h["dependent_tax_exempt_interest"] + ) + 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 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) + + (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 + + 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, + spouse_tax_exempt_interest=0, + dependent_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, + 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, + 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 b5efcf83026..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 @@ -1,6 +1,26 @@ 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 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 = [ + "section_911_excluded_income", + "specified_possession_income", + "puerto_rico_income", +] +# 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"] + class medicare_irmaa_magi_two_years_prior(Variable): value_type = float @@ -8,12 +28,18 @@ 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", + "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 " - "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 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), " @@ -23,25 +49,45 @@ 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_exempt_interest_income"], + ["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) 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 - 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: + 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 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 4cfd3055425..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,9 +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 - agi = tax_unit("adjusted_gross_income", period) + # 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 = agi <= 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 f4890b5288a..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,12 +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 AGI in current year for now. - agi = tax_unit("adjusted_gross_income", period) + # 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 = agi <= 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/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/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 601559b2e36..b217c397344 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). The net investment income tax adds back only the section 911(a)(1) part; see niit_magi_section_911_addition." + 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. The net investment income tax adds back only the section 911(a)(1) part; see niit_magi_section_911_addition." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911", diff --git a/policyengine_us/variables/gov/irs/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 b29ddf98a29..783e272bca5 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,33 +7,43 @@ 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 = tax_unit_non_dep_add( person.tax_unit, @@ -41,18 +51,23 @@ def formula(person, period, parameters): other_alds, include_dependents=p_irs.ald.filer_amounts_recorded_on_dependents, ) + # 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) - ald_sum_taxunit_shared = not_dependent * ald_sum_taxunit * frac - + taxunit_adjustment_shared = ( + not_dependent * (section_911_excluded_income - 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 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..6e0b29006be --- /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,41 @@ +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 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 = ( + "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", + "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 1aaf3f09dec..107d4ae5b4c 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,10 @@ 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 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 = [ 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)