diff --git a/changelog.d/foreign-earned-income-leaves.added.md b/changelog.d/foreign-earned-income-leaves.added.md new file mode 100644 index 00000000000..322ed417d63 --- /dev/null +++ b/changelog.d/foreign-earned-income-leaves.added.md @@ -0,0 +1 @@ +Add TaxUnit leaf inputs for the section 911 earned-income and housing exclusions, allocable deductions, housing deduction, and federal tax worksheet adjustments. diff --git a/changelog.d/ma-gross-income-feie-addback.fixed.md b/changelog.d/ma-gross-income-feie-addback.fixed.md new file mode 100644 index 00000000000..0d46e8479e3 --- /dev/null +++ b/changelog.d/ma-gross-income-feie-addback.fixed.md @@ -0,0 +1,2 @@ +Add section 911 gross foreign earned income and housing exclusions to Massachusetts gross income, derived from Form 2555 leaf inputs while preserving legacy federal stacking inputs and explicit Massachusetts overrides. +Share section 911 election detection across refundable CTC, federal and state EITC, and filing tests so housing-only and leaf-only Form 2555 filers remain recognized when aggregate amounts are zero. diff --git a/changelog.d/section-911-leaf-integration.fixed.md b/changelog.d/section-911-leaf-integration.fixed.md new file mode 100644 index 00000000000..3df41641112 --- /dev/null +++ b/changelog.d/section-911-leaf-integration.fixed.md @@ -0,0 +1 @@ +Derive the section 911 MAGI add-back from Form 2555 leaves before worksheet deductions, recognize Form 2555 filers with zero stacking amounts for CTC Worksheet B, and reject ambiguous mixed-input tax-unit batches. diff --git a/policyengine_us/data/economic_assumptions.py b/policyengine_us/data/economic_assumptions.py index c72459f9833..14347e5fb70 100644 --- a/policyengine_us/data/economic_assumptions.py +++ b/policyengine_us/data/economic_assumptions.py @@ -26,6 +26,7 @@ "employment_income": "calibration.gov.irs.soi.employment_income", "employment_income_last_year": "calibration.gov.irs.soi.employment_income", "energy_efficient_home_improvement_credit": DEFAULT_MICRODATA_UPRATING, + "foreign_earned_income_exclusion": DEFAULT_MICRODATA_UPRATING, "foreign_tax_credit": DEFAULT_MICRODATA_UPRATING, "interest_deduction": DEFAULT_MICRODATA_UPRATING, "long_term_capital_gains": "calibration.gov.irs.soi.long_term_capital_gains", diff --git a/policyengine_us/system.py b/policyengine_us/system.py index 158e6443f43..c4c90688ea2 100644 --- a/policyengine_us/system.py +++ b/policyengine_us/system.py @@ -35,6 +35,7 @@ uprate_parameters, ) from .tools.default_uprating import add_default_uprating +from .tools.section_911 import validate_section_911_batch_inputs from .tools.per_capita_uprating import ( add_per_capita_parameters_for_parameter_uprating, add_per_capita_uprating, @@ -245,6 +246,10 @@ def __init__(self, *args, **kwargs): args, kwargs = self._prepare_spm_system( args, kwargs, kwargs.pop("spm", None), start_instant ) + validate_section_911_batch_inputs( + kwargs.get("situation"), + kwargs.get("default_input_period") or self.default_input_period, + ) super().__init__(*args, **kwargs) reform = create_structural_reforms_from_parameters( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml index aac331e1b63..44f9e6f5b7f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/ctc_credit_limit_worksheet_b_applies.yaml @@ -20,6 +20,26 @@ output: ctc_credit_limit_worksheet_b_applies: false +- name: Form 2555 filer with a zero net stacking amount cannot use Worksheet B. + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_earned_income_exclusion_amount: 1_000 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + output: + foreign_earned_income_exclusion: 0 + ctc_credit_limit_worksheet_b_applies: false + +- name: Housing deduction filer cannot use Worksheet B when worksheet deductions offset the stacking amount. + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 500 + output: + foreign_earned_income_exclusion: 0 + ctc_credit_limit_worksheet_b_applies: false + - name: Does not apply in 2021, when the CTC was fully refundable. period: 2021 input: @@ -61,3 +81,17 @@ refundable_ctc_barred_by_section_911_exclusion: false output: ctc_credit_limit_worksheet_b_applies: true + +- name: A housing-only leaf election with zero aggregates skips Worksheet B. + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + refundable_ctc_barred_by_section_911_exclusion: true + ctc_credit_limit_worksheet_b_applies: false diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc.yaml index efeec12296e..4b6ca135f18 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc.yaml @@ -189,3 +189,20 @@ foreign_earned_income_exclusion: 30_000 output: refundable_ctc: 6_000 + +- name: Housing-only leaf election denies an otherwise positive refundable credit with zero aggregates + period: 2025 + input: + ctc_refundable_maximum: 3_400 + ctc: 4_400 + ctc_phase_out: 0 + ctc_phase_in: 10_000 + ctc_limiting_tax_liability: 0 + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + # Without the section 911 bar, the same phase-in gives a $3,400 refund. + refundable_ctc: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.yaml index 3bb22883ff1..4a1f10bf6fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.yaml @@ -47,3 +47,48 @@ foreign_earned_income_exclusion: 30_000 output: refundable_ctc_barred_by_section_911_exclusion: false + +# Section 911(a)(2) covers the housing exclusion even when the earned-income +# exclusion is zero. Deductions can reduce the derived aggregates to zero +# without undoing the Form 2555 election. +- name: A housing-only leaf election is barred when deductions cancel both aggregates + period: 2025 + input: + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + refundable_ctc_barred_by_section_911_exclusion: true + +- name: A housing-only election is barred with an explicit zero stacking override + period: 2025 + input: + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion: 0 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion: 0 + refundable_ctc_barred_by_section_911_exclusion: true + +- name: An earned-income leaf election is barred when deductions cancel both aggregates + period: 2025 + input: + foreign_earned_income_exclusion_amount: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + refundable_ctc_barred_by_section_911_exclusion: true + +- name: A positive section 911 MAGI amount is barred with zero stacking + period: 2025 + input: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 5_000 + output: + foreign_earned_income_exclusion: 0 + refundable_ctc_barred_by_section_911_exclusion: true diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_eligible.yaml index 79a909e9c37..b8de8659af6 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/earned_income/eitc_eligible.yaml @@ -74,3 +74,63 @@ foreign_earned_income_exclusion: 30_000 output: eitc_eligible: false + +# Section 32(c)(1)(C) also denies the EITC for the housing exclusion under +# section 911(a)(2), including leaf elections with zero derived aggregates. +- name: A housing-only leaf election is ineligible when deductions cancel both aggregates. + period: 2025 + input: + eitc_investment_income_eligible: true + eitc_demographic_eligible: true + filer_meets_eitc_identification_requirements: true + filing_status: JOINT + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + eitc_eligible: false + +- name: A housing-only election is ineligible with an explicit zero stacking override. + period: 2025 + input: + eitc_investment_income_eligible: true + eitc_demographic_eligible: true + filer_meets_eitc_identification_requirements: true + filing_status: JOINT + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion: 0 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion: 0 + eitc_eligible: false + +- name: An earned-income leaf election is ineligible when deductions cancel both aggregates. + period: 2025 + input: + eitc_investment_income_eligible: true + eitc_demographic_eligible: true + filer_meets_eitc_identification_requirements: true + filing_status: JOINT + foreign_earned_income_exclusion_amount: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + eitc_eligible: false + +- name: A positive section 911 MAGI amount is ineligible with zero stacking. + period: 2025 + input: + eitc_investment_income_eligible: true + eitc_demographic_eligible: true + filer_meets_eitc_identification_requirements: true + filing_status: JOINT + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 5_000 + output: + foreign_earned_income_exclusion: 0 + eitc_eligible: false 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 index 65038ebf808..4c3977ea8fc 100644 --- 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 @@ -37,3 +37,58 @@ # 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 + +- name: Worksheet deductions reduce stacking but not Form 2555 lines 45 and 50 + period: 2025 + input: + people: + person1: + age: 40 + irs_gross_income: 50_000 + person2: + age: 40 + irs_gross_income: 50_000 + tax_units: + tax_unit1: + members: [person1] + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 0 + tax_unit2: + members: [person2] + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + households: + household1: + members: [person1] + state_code: MA + household2: + members: [person2] + state_code: MA + output: + # Schedule 1-A lines 2b and 2c add Form 2555 lines 45 and 50. + section_911_excluded_income: [9_500, 9_500] + # Foreign Earned Income Tax Worksheet line 2b affects stacking only. + foreign_earned_income_exclusion: [9_500, 8_800] + +- name: A negative Form 2555 line 45 remains signed before the stacking floor + period: 2025 + input: + age: 40 + irs_gross_income: 50_000 + state_code: MA + foreign_earned_income_exclusion_amount: 1_000 + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + foreign_housing_deduction: 0 + foreign_earned_income_exclusion_disallowed_deductions: 0 + output: + # Publication 54, How To Report Deductions, Example 4 permits line 44 + # to exceed line 43. The zero floor belongs to worksheet line 2c. + section_911_excluded_income: -1_000 + foreign_earned_income_exclusion: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml index 70ff23859c7..9be76ab7ef9 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/senior_deduction/additional_senior_deduction_magi.yaml @@ -63,3 +63,28 @@ puerto_rico_income: 10_000 output: additional_senior_deduction_magi: 110_000 + +- name: Senior MAGI adds Form 2555 lines 45 and 50 before worksheet deductions + period: 2025 + input: + people: + person1: + age: 40 + irs_gross_income: 50_000 + tax_units: + tax_unit: + members: [person1] + adjusted_gross_income: 100_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + households: + household: + members: [person1] + state_code: MA + output: + # Schedule 1-A adds lines 45 and 50 (9,500), while the tax worksheet + # subtracts the 700 of related itemized deductions for stacking only. + additional_senior_deduction_magi: 109_500 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit/tax_unit_is_filer.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit/tax_unit_is_filer.yaml index 47d5c22d186..1b383c33b38 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax_unit/tax_unit_is_filer.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax_unit/tax_unit_is_filer.yaml @@ -185,3 +185,33 @@ output: tax_unit_is_required_to_file: false tax_unit_is_filer: false + +- name: A housing-only leaf claimant with zero aggregates files according to tax-benefit propensity + period: 2025 + input: + tax_unit_is_required_to_file: false + eligible_for_refundable_credits: false + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: false + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + tax_unit_is_filer: true + +- name: A housing-only leaf claimant with zero aggregates and no filing propensity does not file + period: 2025 + input: + tax_unit_is_required_to_file: false + eligible_for_refundable_credits: false + would_file_if_eligible_for_refundable_credit: false + would_file_taxes_voluntarily: false + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + tax_unit_is_filer: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml new file mode 100644 index 00000000000..855448e1dd1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/foreign_earned_income.yaml @@ -0,0 +1,423 @@ +# M.G.L. c. 62 s. 2(a)(1)(C) adds foreign earned income excluded under +# section 911 of the Code to Massachusetts gross income, and s. 2(b)(2) puts +# it in Part B. Other income inputs must already exclude the excluded amount. + +- name: Excluded foreign wages enter Part B and preserve Parts A and C + period: 2025 + input: + state_code: MA + age: 35 + employment_income: 32_000 + foreign_earned_income_exclusion: 10_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + ma_part_b_taxable_income_deductions: 0 + ma_part_b_taxable_income_exemption: 4_400 + output: + # Federal: 32,000 + 6,000 + 3,000 + 4,000. + irs_gross_income: 45_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + # MA: 45,000 + 10,000. + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + ma_part_b_taxable_income: 37_600 + # (37,600 + 6,000 + 4,000) * 5% + 3,000 * 8.5%. + ma_income_tax_before_credits: 2_635 + +- name: Fully excluded foreign wages remain Massachusetts income + period: 2025 + input: + state_code: MA + irs_gross_income: 0 + foreign_earned_income_exclusion: 10_000 + output: + # 0 + 10,000. + ma_gross_income: 10_000 + ma_part_b_gross_income: 10_000 + +- name: Foreign earnings addition preserves loss adjustment + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + ma_gross_income_loss_adjustment: -15_000 + output: + # 50,000 - 15,000 + 10,000. + ma_gross_income: 45_000 + ma_part_b_gross_income: 45_000 + +- name: Foreign earnings addition preserves existing Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 60_000 + foreign_earned_income_exclusion: 20_000 + taxable_social_security: 7_500 + salt_refund_income: 2_000 + taxable_public_pension_income: 10_000 + output: + # 60,000 + 20,000 - 7,500 - 2,000 - 10,000. + ma_gross_income: 60_500 + ma_part_b_gross_income: 60_500 + +- name: Gross exclusion differs from net federal stacking + period: 2025 + input: + state_code: MA + irs_gross_income: 45_000 + foreign_earned_income_exclusion: 8_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + output: + # No housing deduction or allocable ALD; disallowed itemized expenses 2,000. + # Stacking = 10,000 - 2,000; MA gross = 45,000 + 10,000. + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + +- name: An entered zero addition replaces the federal stacking default + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + output: + ma_foreign_earned_income_exclusion_addback: 0 + # 50,000 + 0. + ma_gross_income: 50_000 + ma_part_b_gross_income: 50_000 + +- name: The addition applies only in Massachusetts + period: 2025 + input: + state_code: NY + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + output: + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 0 + +- name: A negative federal exclusion cannot reduce Massachusetts gross income + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: -5_000 + output: + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + ma_part_b_gross_income: 50_000 + +- name: The addition counts against losses before the zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 10_000 + foreign_earned_income_exclusion: 25_000 + ma_gross_income_loss_adjustment: -30_000 + output: + # max(0, 10,000 - 30,000 + 25,000). + ma_gross_income: 5_000 + ma_part_b_gross_income: 5_000 + +# Leaf-input regressions for ruling d1051 (Form 2555 lines 42 and 36). +# Preserve the existing legacy aggregate-input fixtures alongside these cases. + +- name: Leaf exclusions enter Part B and preserve Parts A and C + period: 2025 + input: + state_code: MA + age: 35 + employment_income: 32_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + ma_part_b_taxable_income_deductions: 0 + ma_part_b_taxable_income_exemption: 4_400 + output: + irs_gross_income: 45_000 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + ma_part_b_taxable_income: 37_600 + ma_income_tax_before_credits: 2_635 + +- name: Fully excluded wages supplied as leaves remain Massachusetts income + period: 2025 + input: + state_code: MA + irs_gross_income: 0 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 10_000 + ma_part_b_gross_income: 10_000 + +- name: Leaf exclusions preserve the Massachusetts loss adjustment + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + ma_gross_income_loss_adjustment: -15_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + ma_gross_income: 45_000 + ma_part_b_gross_income: 45_000 + +- name: Leaf exclusions preserve existing Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 60_000 + foreign_earned_income_exclusion_amount: 16_000 + foreign_housing_exclusion: 4_000 + taxable_social_security: 7_500 + salt_refund_income: 2_000 + taxable_public_pension_income: 10_000 + output: + foreign_earned_income_exclusion_gross: 20_000 + ma_gross_income: 60_500 + ma_part_b_gross_income: 60_500 + +- name: Gross leaf exclusions differ from an explicit legacy federal stacking input + period: 2025 + input: + state_code: MA + irs_gross_income: 45_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion: 8_000 + dividend_income: 6_000 + short_term_capital_gains: 3_000 + long_term_capital_gains: 4_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 8_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 55_000 + ma_part_a_gross_income: 9_000 + ma_part_c_gross_income: 4_000 + ma_part_b_gross_income: 42_000 + +- name: Leaf exclusions count against losses before the Massachusetts zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 10_000 + foreign_earned_income_exclusion_amount: 20_000 + foreign_housing_exclusion: 5_000 + ma_gross_income_loss_adjustment: -30_000 + output: + foreign_earned_income_exclusion_gross: 25_000 + ma_gross_income: 5_000 + ma_part_b_gross_income: 5_000 + +- name: Section 911 earned-income exclusion alone derives the gross addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 10_000 + output: + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Section 911 housing exclusion alone derives the gross addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_housing_exclusion: 10_000 + output: + foreign_earned_income_exclusion_amount: 0 + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit zero federal aggregate overrides derived stacking without overriding gross leaves + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion: 0 + output: + foreign_earned_income_exclusion: 0 + foreign_earned_income_exclusion_gross: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit zero leaves override the Massachusetts legacy fallback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 0 + foreign_earned_income_exclusion: 10_000 + output: + foreign_earned_income_exclusion: 10_000 + foreign_earned_income_exclusion_gross: 0 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Federal worksheet deductions preserve gross Massachusetts exclusions + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + foreign_earned_income_exclusion_allocable_deductions: 1_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 700 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 8_800 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: The derived federal worksheet amount has its own zero floor + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 1_000 + foreign_earned_income_exclusion_allocable_deductions: 2_000 + output: + foreign_earned_income_exclusion_gross: 1_000 + foreign_earned_income_exclusion: 0 + ma_foreign_earned_income_exclusion_addback: 1_000 + ma_gross_income: 51_000 + +- name: Housing deduction alone stacks federally without a Massachusetts exclusion addback + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_housing_deduction: 500 + output: + foreign_earned_income_exclusion_gross: 0 + foreign_earned_income_exclusion: 500 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: An explicit Massachusetts zero overrides positive gross exclusion leaves + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + ma_foreign_earned_income_exclusion_addback: 0 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Leaf exclusions do not create Massachusetts income outside Massachusetts + period: 2025 + input: + state_code: NY + irs_gross_income: 50_000 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion_gross: 10_000 + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 0 + +- name: Legacy aggregate input still supplies the Massachusetts default + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: 10_000 + output: + foreign_earned_income_exclusion: 10_000 + ma_foreign_earned_income_exclusion_addback: 10_000 + ma_gross_income: 60_000 + +- name: Explicit negative legacy federal input remains directly settable + period: 2025 + input: + state_code: MA + irs_gross_income: 50_000 + foreign_earned_income_exclusion: -5_000 + output: + foreign_earned_income_exclusion: -5_000 + ma_foreign_earned_income_exclusion_addback: 0 + ma_gross_income: 50_000 + +- name: Leaf worksheet adjustments reproduce the federal head-of-household stacking example + absolute_error_margin: 0.01 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 60_000 + child1: + age: 10 + child2: + age: 8 + tax_units: + tax_unit: + members: [parent, child1, child2] + foreign_earned_income_exclusion_amount: 40_000 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 4_000 + foreign_housing_deduction: 500 + foreign_earned_income_exclusion_disallowed_deductions: 1_500 + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + # Same federal worksheet net as the existing $40,000 legacy fixture: + # 45,000 - 4,000 + 500 - 1,500 = 40,000 (worksheet line 2c). + foreign_earned_income_exclusion_gross: 45_000 + foreign_earned_income_exclusion: 40_000 + adjusted_gross_income: 60_000 + taxable_income: 36_375 + taxable_income_plus_section_911_exclusion: 76_375 + # Official 2025 brackets: tax(76,375) - tax(40,000). + income_tax_main_rates: 5_517.50 + income_tax_before_credits: 5_517.50 + ctc_limiting_tax_liability: 5_517.50 + +- name: Leaf exclusions preserve the existing Form 2555 Worksheet B check + period: 2025 + input: + ctc_qualifying_children: 2 + foreign_earned_income_exclusion_amount: 8_000 + foreign_housing_exclusion: 2_000 + output: + foreign_earned_income_exclusion: 10_000 + ctc_credit_limit_worksheet_b_applies: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml index 007a22c4a0f..5bf0bb465e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/gross_income/ma_part_b_gross_income.yaml @@ -226,3 +226,39 @@ ma_part_c_agi: 4_000 # 5% x 53,600 + 8.5% x 3,000 + 5% x 4,000 = 2,680 + 255 + 200. ma_income_tax_before_credits: 3_135 + +- name: Foreign-earned leaves preserve Part B earnings with offsetting capital gains + period: 2025 + input: + people: + head1: + age: 40 + short_term_capital_gains: 5_000 + long_term_capital_gains: -2_000 + head2: + age: 40 + short_term_capital_gains: 5_000 + long_term_capital_gains: -2_000 + tax_units: + tax_unit1: + members: [head1] + foreign_earned_income_exclusion_amount: 0 + tax_unit2: + members: [head2] + foreign_earned_income_exclusion_amount: 7_500 + households: + household1: + members: [head1] + state_code: MA + household2: + members: [head2] + state_code: MA + output: + # The federal gross-income starting point includes net capital gains. + irs_gross_income: [3_000, 3_000] + ma_foreign_earned_income_exclusion_addback: [0, 7_500] + ma_gross_income: [3_000, 10_500] + # c.62 s.2(b)(2), (c)(2)(b), and (d)(1)(M): capital losses offset + # capital gains on Schedules B/D; they cannot reduce Part B earnings. + ma_part_b_gross_income: [0, 7_500] + ma_part_a_agi: [3_000, 3_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.yaml index a94a5ac6133..048a0ebd39a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.yaml @@ -133,3 +133,26 @@ foreign_earned_income_exclusion: 30_000 output: nj_childless_eitc_age_eligible: false + +- name: Housing-only leaf claimant fails the non-age qualification with zero aggregates. + period: 2025 + absolute_error_margin: 0 + input: + people: + person1: + age: 20 + households: + household: + members: [person1] + state_code: NJ + tax_units: + tax_unit: + members: [person1] + nj_eitc_income_eligible: true + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + nj_childless_eitc_age_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/section_911_state_eitc.yaml b/policyengine_us/tests/policy/baseline/gov/states/section_911_state_eitc.yaml index 4bec3bf257c..9f13e904667 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/section_911_state_eitc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/section_911_state_eitc.yaml @@ -124,3 +124,32 @@ tax_unit_is_required_to_file: true eitc: 0 in_eitc_eligible: false + +- name: Housing-only leaf claimant in DC gets no recomputed EITC with zero aggregates. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 5_000 + tax_units: + tax_unit: + members: [person1] + foreign_earned_income_exclusion_amount: 0 + foreign_housing_exclusion: 5_000 + foreign_earned_income_exclusion_allocable_deductions: 5_000 + # Ensure a reason to file remains when the aggregates are zero, so + # the section 911 eligibility rule is what denies the state credit. + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: false + households: + household: + members: [person1] + state_code: DC + output: + foreign_earned_income_exclusion: 0 + section_911_excluded_income: 0 + tax_unit_is_required_to_file: false + eitc: 0 + dc_eitc_without_qualifying_child: 0 diff --git a/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py new file mode 100644 index 00000000000..2510da6ef04 --- /dev/null +++ b/policyengine_us/tests/test_foreign_earned_income_exclusion_leaves.py @@ -0,0 +1,249 @@ +"""Leaf-input identities, worksheet semantics, and legacy-input precedence.""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEAR = 2025 +LEAVES = ( + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", +) + + +def simulation_for_tax_units(inputs): + """Independent single-person tax units, with uniform input presence.""" + people, tax_units, households = {}, {}, {} + groups = {"marital_units": {}, "spm_units": {}, "families": {}} + for index, tax_unit_inputs in enumerate(inputs): + person = f"person_{index}" + members = [person] + people[person] = { + "age": {YEAR: 40}, + "irs_gross_income": {YEAR: 50_000}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + **tax_unit_inputs, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {YEAR: "MA", YEAR + 1: "MA"}, + } + for name in groups: + groups[name][f"{name}_{index}"] = {"members": members} + return Simulation( + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + **groups, + } + ) + + +def leaf_simulation(rows): + return simulation_for_tax_units( + [{name: {YEAR: value} for name, value in zip(LEAVES, row)} for row in rows] + ) + + +@hypothesis.settings(max_examples=5, deadline=None, derandomize=True) +@hypothesis.example([(-10_000, 1_000, 0, 0, 0), (8_000, 2_000, 1_000, 500, 700)]) +@hypothesis.given( + st.lists( + st.tuples( + st.integers(-100_000, 150_000), + st.integers(-100_000, 150_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + ), + min_size=1, + max_size=8, + ) +) +def test_gross_sum_and_nonnegative_massachusetts_addback(rows): + simulation = leaf_simulation(rows) + amounts = np.asarray(rows, dtype=float) + gross = amounts[:, 0] + amounts[:, 1] + section_911 = gross - amounts[:, 2] + amounts[:, 3] + federal = np.maximum(0, section_911 - amounts[:, 4]) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR), + gross, + ) + addback = simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR) + np.testing.assert_array_equal(addback, np.maximum(0, gross)) + assert (addback >= 0).all() + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), federal + ) + # MAGI adds Form 2555 lines 45 and 50, before worksheet line 2b. + np.testing.assert_array_equal( + simulation.calculate("section_911_excluded_income", YEAR), section_911 + ) + + +@pytest.mark.parametrize( + "first", + [ + "foreign_earned_income_exclusion_gross", + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + ], +) +def test_explicit_zero_leaves_are_period_specific_and_ignore_default_caches(first): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion": { + YEAR: 9_000, + YEAR + 1: 11_000, + }, + "foreign_earned_income_exclusion_amount": {YEAR + 1: 0}, + "foreign_housing_exclusion": {YEAR + 1: 0}, + } + ] + ) + # Computing an unset leaf or the gross formula in another period must + # not turn its default zero into an explicitly supplied leaf input. + simulation.calculate(first, YEAR) + simulation.calculate(first, YEAR + 1) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [9_000] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), + [11_000], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR), + [9_000], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + [0], + ) + + +@pytest.mark.parametrize("entered", [0, 6_000]) +def test_set_input_marks_a_leaf_explicit_after_its_default_was_cached(entered): + simulation = simulation_for_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: 10_000}}] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_housing_exclusion", YEAR), [0] + ) + simulation.set_input( + "foreign_housing_exclusion", YEAR, np.array([entered], dtype=float) + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR), + [entered], + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR), + [entered], + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [10_000] + ) + + +def expected_legacy_carry(simulation, entered): + # The original pure dollar input used default uprating, then the system + # converted that national total to its per-capita series. Adding a + # formula must preserve the projected value. + parameters = simulation.tax_benefit_system.parameters + before = parameters( + str(YEAR) + ).calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + after = parameters( + str(YEAR + 1) + ).calibration.gov.cbo.income_by_source.adjusted_gross_income_per_capita + return np.array([entered], dtype=np.float32) * (after / before) + + +@pytest.mark.parametrize("legacy", [0, 10_000, -5_000]) +def test_legacy_aggregate_keeps_its_existing_carry_over(legacy): + simulation = simulation_for_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: legacy}}] + ) + expected = expected_legacy_carry(simulation, legacy) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), + expected, + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + np.maximum(0, expected), + ) + + +def test_carried_leaf_inputs_keep_gross_and_federal_net_distinct(): + simulation = leaf_simulation([(8_000, 2_000, 1_000, 0, 0)]) + federal = simulation.calculate("foreign_earned_income_exclusion", YEAR + 1) + earned = simulation.calculate("foreign_earned_income_exclusion_amount", YEAR + 1) + housing = simulation.calculate("foreign_housing_exclusion", YEAR + 1) + deductions = simulation.calculate( + "foreign_earned_income_exclusion_allocable_deductions", YEAR + 1 + ) + gross = earned + housing + np.testing.assert_array_equal(federal, gross - deductions) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion_gross", YEAR + 1), gross + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + gross, + ) + assert (gross > federal).all() + + +def test_future_leaf_inputs_do_not_replace_legacy_carry(): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_earned_income_exclusion_amount": {YEAR + 2: 20_000}, + } + ] + ) + expected = expected_legacy_carry(simulation, 10_000) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR + 1), expected + ) + np.testing.assert_array_equal( + simulation.calculate("ma_foreign_earned_income_exclusion_addback", YEAR + 1), + expected, + ) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.given(st.integers(1, 150_000), st.integers(0, 150_000)) +def test_form_2555_filers_skip_worksheet_b_even_when_stacking_is_zero(amount, excess): + simulation = simulation_for_tax_units( + [ + { + "foreign_earned_income_exclusion_amount": {YEAR: amount}, + "foreign_earned_income_exclusion_allocable_deductions": { + YEAR: amount + excess + }, + "ctc_qualifying_children": {YEAR: 1}, + } + ] + ) + np.testing.assert_array_equal( + simulation.calculate("foreign_earned_income_exclusion", YEAR), [0] + ) + np.testing.assert_array_equal( + simulation.calculate("ctc_credit_limit_worksheet_b_applies", YEAR), [False] + ) diff --git a/policyengine_us/tests/test_ma_foreign_earned_income_addback.py b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py new file mode 100644 index 00000000000..f80fc5bf9a8 --- /dev/null +++ b/policyengine_us/tests/test_ma_foreign_earned_income_addback.py @@ -0,0 +1,637 @@ +"""Property and differential tests for the Massachusetts addition of foreign +earned income excluded under section 911. + +M.G.L. c. 62 s. 2(a)(1)(C) adds to federal gross income "Earned income from +foreign sources excluded under section nine hundred and eleven of the Code", +and s. 2(b)(2) makes Part B gross income the Massachusetts gross income "not +included in Part A or Part C gross income". Part B removes the dividends and +positive net capital gain included in federal gross income; capital losses do +not reduce Part B under s. 2(d)(1)(M). Massachusetts Part A and Part C netting +can differ from federal netting, so their separate gross amounts are not the +subtraction used to isolate Part B. The excluded amount is +`ma_foreign_earned_income_exclusion_addback` (Form 2555 line 43). It defaults +to `foreign_earned_income_exclusion`, floored at zero, and can be entered +directly. Before this change, `ma_gross_income` subtracted the exclusion instead. + +Write B for federal gross income plus `ma_gross_income_loss_adjustment`, less +taxable Social Security, state and local tax refunds and exempt public +pensions. Properties for nonnegative exclusions and entered addbacks: + +1. Massachusetts gross income is max(0, B + addback), where the addback is + the entered amount or, when none is entered, the nonnegative exclusion. + Against the formula before this change, max(0, B - exclusion), gross income + rises by the addback plus the exclusion wherever neither zero floor binds. + Outside Massachusetts, or with no exclusion and no entered addback, gross + income, Part B and the taxes are bit-for-bit unchanged. Massachusetts tax + before credits never falls. +2. Raising the addback by D leaves federal gross income, the loss adjustment + and Parts A and C unchanged, and leaves AGI unchanged for these + households, none of which has an AGI item that reads the exclusion. It + raises gross income by max(0, B + addback + D) - max(0, B + addback) and + Part B by the matching change past its own floor, so by exactly D where + neither floor binds. The same holds when the exclusion rises and no + addback is entered. +3. Gross income is never negative, is zero while B + addback is at most + zero, equals B + addback where the addback lifts a negative B above zero, + and never falls as the addback rises. +4. An entered addback, including zero, replaces the default. An entered zero + beside a positive exclusion gives the Massachusetts gross income results + of a household with no exclusion, and an entered amount gives the same + results whatever the exclusion. +""" + +import gc +from functools import lru_cache + +import numpy as np +import pytest + +from policyengine_us import CountryTaxBenefitSystem, Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +YEARS = [2025, 2026] +DELTA = 7_500 + +GROSS = [ + "ma_foreign_earned_income_exclusion_addback", + "ma_gross_income", + "ma_part_a_gross_income", + "ma_part_b_gross_income", + "ma_part_c_gross_income", +] +TAX_UNIT_COMPONENTS = ["adjusted_gross_income", "ma_gross_income_loss_adjustment"] +PERSON_COMPONENTS = [ + "irs_gross_income", + "taxable_social_security", + "salt_refund_income", + "taxable_public_pension_income", +] +TAXES = [ + "ma_income_tax_before_credits", + "ma_income_tax", + "state_income_tax", + "income_tax", +] +# Federal and Part A and C amounts the addback must not move. +FIXED_BY_ADDBACK = [ + "irs_gross_income", + "adjusted_gross_income", + "ma_gross_income_loss_adjustment", + "ma_part_a_gross_income", + "ma_part_c_gross_income", +] + + +def household(state, status, wages, exclusion, **amounts): + return { + "state": state, + "status": status, + "wages": wages, + "spouse_wages": amounts.get("spouse_wages", 0), + "dividends": amounts.get("dividends", 0), + "short_term_gains": amounts.get("short_term_gains", 0), + "long_term_gains": amounts.get("long_term_gains", 0), + "self_employment": amounts.get("self_employment", 0), + "public_pension": amounts.get("public_pension", 0), + "salt_refund": amounts.get("salt_refund", 0), + "exclusion": exclusion, + "entered": amounts.get("entered"), + } + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + person_tax_unit = [] + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["dividends"]}, + "short_term_capital_gains": {year: h["short_term_gains"]}, + "long_term_capital_gains": {year: h["long_term_gains"]}, + "self_employment_income": {year: h["self_employment"]}, + "taxable_public_pension_income": {year: h["public_pension"]}, + "salt_refund_income": {year: h["salt_refund"]}, + } + members = [head] + person_tax_unit.append(i) + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = { + "age": {year: 45}, + "employment_income": {year: h["spouse_wages"]}, + } + members.append(spouse) + person_tax_unit.append(i) + marital_units[f"marital_unit_{i}"] = {"members": list(members)} + tax_unit = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single). + "filing_status": {year: h["status"]}, + "foreign_earned_income_exclusion": {year: h["exclusion"]}, + } + if h["entered"] is not None: + tax_unit["ma_foreign_earned_income_exclusion_addback"] = { + year: h["entered"] + } + tax_units[f"tax_unit_{i}"] = tax_unit + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: h["state"]}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + situation = { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + return situation, np.array(person_tax_unit) + + +def before_the_addition(): + """A reform restoring `ma_gross_income` as it was before this change.""" + from policyengine_core.reforms import Reform + + from policyengine_us.model_api import ( + USD, + YEAR, + StateCode, + TaxUnit, + Variable, + add, + max_, + ) + + class ma_gross_income(Variable): + value_type = float + entity = TaxUnit + label = "MA gross income before the section 911 addition" + unit = USD + definition_period = YEAR + defined_for = StateCode.MA + + def formula(tax_unit, period, parameters): + federal_gross_income = add(tax_unit, period, ["irs_gross_income"]) + loss_adjustment = tax_unit("ma_gross_income_loss_adjustment", period) + foreign_earned_income = tax_unit("foreign_earned_income_exclusion", period) + social_security_in_agi = add(tax_unit, period, ["taxable_social_security"]) + salt_refund_income = add(tax_unit, period, ["salt_refund_income"]) + public_pension = add(tax_unit, period, ["taxable_public_pension_income"]) + deductions = ( + foreign_earned_income + + social_security_in_agi + + salt_refund_income + + public_pension + ) + return max_(0, federal_gross_income + loss_adjustment - deductions) + + class reform(Reform): + def apply(self): + self.update_variable(ma_gross_income) + + return reform + + +@lru_cache(maxsize=None) +def system_before_the_addition(): + """Built once: constructing a reformed system takes seconds.""" + return CountryTaxBenefitSystem(reform=before_the_addition()) + + +@pytest.fixture(scope="module", autouse=True) +def release_cached_systems(): + """Release the reformed system and cached results when the module ends, + so the rest of the pytest process does not keep a second full system + resident.""" + yield + system_before_the_addition.cache_clear() + _calculate.cache_clear() + gc.collect() + + +def calculate_batch(households, year, before, taxes): + situation, person_tax_unit = build_situation(households, year) + simulation = Simulation( + situation=situation, + **({"tax_benefit_system": system_before_the_addition()} if before else {}), + ) + results = {} + for v in GROSS + TAX_UNIT_COMPONENTS + (TAXES if taxes else []): + results[v] = np.asarray(simulation.calculate(v, year), dtype=float) + for v in PERSON_COMPONENTS: + values = np.asarray(simulation.calculate(v, year), dtype=float) + results[v] = np.bincount( + person_tax_unit, weights=values, minlength=len(households) + ) + return results + + +def freeze(households): + return tuple(tuple(sorted(h.items())) for h in households) + + +@lru_cache(maxsize=None) +def _calculate(frozen, year, before, taxes): + households = [dict(h) for h in frozen] + # An input given to only some tax units in a batch sets the rest to the + # default (zero), not to the value the formula would compute, so + # households with and without an entered addback run as separate batches. + entered = np.array([h["entered"] is not None for h in households]) + results = {} + for group in [entered, ~entered]: + if not group.any(): + continue + batch = calculate_batch( + [h for h, g in zip(households, group) if g], year, before, taxes + ) + for v, values in batch.items(): + results.setdefault(v, np.empty(len(households))) + results[v][group] = values + return results + + +def calculate(households, year, before=False, taxes=False): + return _calculate(freeze(households), year, before, taxes) + + +def calculate_variants(variants, year): + """Several versions of the same households, run as one batch. + + Households are independent, so stacking the versions in one simulation + gives each the results it would get alone, with fewer simulations. + """ + results = calculate([h for variant in variants for h in variant], year) + sizes = np.cumsum([0] + [len(variant) for variant in variants]) + return [ + {v: values[start:end] for v, values in results.items()} + for start, end in zip(sizes[:-1], sizes[1:]) + ] + + +def tolerance(*amounts): + """One cent, plus the rounding of single-precision arithmetic. + + The model stores amounts as 32-bit floats, which carry about seven + significant digits. + """ + return 0.01 + 5e-7 * np.max(np.abs(np.array(amounts, dtype=float)), axis=0) + + +def in_massachusetts(households): + return np.array([h["state"] == "MA" for h in households]) + + +def expected_addback(households): + """The entered addback, or else the exclusion (Massachusetts only).""" + return np.array( + [ + (h["exclusion"] if h["entered"] is None else h["entered"]) + if h["state"] == "MA" + else 0 + for h in households + ], + dtype=float, + ) + + +def exclusions(households): + return np.array([h["exclusion"] for h in households], dtype=float) + + +def federally_included_capital_income(households): + """Independent capital-income reference from the household input amounts. + + This fixture puts all dividends and gains on the head; spouses have only + wages, and no capital gain distributions are supplied outside Schedule D. + """ + # M.G.L. c. 62 § 2(b)(2) excludes capital income from Part B, and + # § 2(d)(1)(M) disallows the federal capital-loss deduction. Remove the + # dividends and positive net gain included federally, rather than Part A + # and Part C amounts whose Massachusetts loss netting can differ (#9954). + return np.array( + [ + h["dividends"] + max(0, h["short_term_gains"] + h["long_term_gains"]) + for h in households + ], + dtype=float, + ) + + +def base_income(results): + """B: federal gross income and the loss adjustment, less the exclusions.""" + return ( + results["irs_gross_income"] + + results["ma_gross_income_loss_adjustment"] + - results["taxable_social_security"] + - results["salt_refund_income"] + - results["taxable_public_pension_income"] + ) + + +def with_entered(households, change): + """The same households with the entered addback set from each one.""" + return [{**h, "entered": change(h)} for h in households] + + +def default_addback(h): + return h["exclusion"] if h["entered"] is None else h["entered"] + + +def check_gross_income_adds_back_foreign_earnings(households, year): + law = calculate(households, year, taxes=True) + before = calculate(households, year, before=True, taxes=True) + ma = in_massachusetts(households) + addback = expected_addback(households) + exclusion = exclusions(households) + base = base_income(law) + + # The default is the exclusion; an entered amount replaces it. + slack = tolerance(addback, law["ma_foreign_earned_income_exclusion_addback"]) + assert ( + np.abs(law["ma_foreign_earned_income_exclusion_addback"][ma] - addback[ma]) + <= slack[ma] + ).all() + + # Gross income is max(0, B + addback) in Massachusetts and zero elsewhere. + gross = np.where(ma, np.maximum(0, base + addback), 0) + slack = tolerance(base, addback, gross, law["ma_gross_income"]) + assert (np.abs(law["ma_gross_income"] - gross) <= slack).all() + + # Differential: the formula before this change subtracted the exclusion. + gross_before = np.where(ma, np.maximum(0, base - exclusion), 0) + assert (np.abs(before["ma_gross_income"] - gross_before) <= slack).all() + unfloored = ma & (base - exclusion >= 0) & (base + addback >= 0) + change = law["ma_gross_income"] - before["ma_gross_income"] + assert ( + np.abs(change - (addback + exclusion))[unfloored] <= 2 * slack[unfloored] + ).all() + assert (law["ma_gross_income"] >= before["ma_gross_income"]).all() + + # Massachusetts tax before credits never falls when gross income rises. + assert ( + law["ma_income_tax_before_credits"] + >= before["ma_income_tax_before_credits"] + - tolerance(law["ma_income_tax_before_credits"]) + ).all() + + # Outside Massachusetts, or with nothing excluded or entered, nothing + # changes, bit for bit. + untouched = ~ma | ((exclusion == 0) & (addback == 0)) + for v in ["ma_gross_income", "ma_part_b_gross_income"] + TAXES: + assert np.array_equal(law[v][untouched], before[v][untouched]), v + assert not law["ma_gross_income"][~ma].any() + return law, before, unfloored, untouched + + +def check_foreign_earnings_enter_part_b_only(households, year): + ma = in_massachusetts(households) + for low_households, high_households in [ + # Raise an entered addback; the exclusion stays put. + ( + with_entered(households, default_addback), + with_entered(households, lambda h: default_addback(h) + DELTA), + ), + # Raise the exclusion with nothing entered. + ( + [{**h, "entered": None} for h in households], + [ + {**h, "exclusion": h["exclusion"] + DELTA, "entered": None} + for h in households + ], + ), + ]: + low, high = calculate_variants([low_households, high_households], year) + for v in FIXED_BY_ADDBACK: + assert np.array_equal(low[v], high[v]), v + base = base_income(low) + addback = expected_addback(low_households) + gross_change = np.where( + ma, + np.maximum(0, base + addback + DELTA) - np.maximum(0, base + addback), + 0, + ) + slack = tolerance(base, addback, high["ma_gross_income"]) + assert ( + np.abs(high["ma_gross_income"] - low["ma_gross_income"] - gross_change) + <= 2 * slack + ).all() + # c.62 § 2(b)(2), § 2(d)(1)(M): remove the capital income included + # federally; different Massachusetts netting cannot reduce Part B. + capital_income = federally_included_capital_income(low_households) + residual_low = low["ma_gross_income"] - capital_income + residual_high = high["ma_gross_income"] - capital_income + part_b_change = np.maximum(0, residual_high) - np.maximum(0, residual_low) + assert ( + np.abs( + high["ma_part_b_gross_income"] + - low["ma_part_b_gross_income"] + - part_b_change + ) + <= 2 * slack + ).all() + # Where neither floor binds, both rise by exactly the change. + outside = ma & (base + addback >= 0) & (residual_low >= 0) + for v in ["ma_gross_income", "ma_part_b_gross_income"]: + rise = high[v] - low[v] + assert (np.abs(rise - DELTA)[outside] <= 2 * slack[outside]).all(), v + assert not (high["ma_gross_income"] - low["ma_gross_income"])[~ma].any() + return outside + + +def check_addition_respects_zero_floor(households, year): + ma = in_massachusetts(households) + law = calculate(households, year, taxes=True) + base = base_income(law) + addback = expected_addback(households) + assert (law["ma_gross_income"] >= 0).all() + assert (law["ma_part_b_gross_income"] >= 0).all() + floored = ma & (base + addback <= 0) + assert not law["ma_gross_income"][floored].any() + # The addition offsets losses before the floor applies: where B is + # negative but B + addback is not, gross income is B + addback. + lifted = ma & (base < 0) & (base + addback > 0) + slack = tolerance(base, addback, law["ma_gross_income"]) + assert ( + np.abs(law["ma_gross_income"] - (base + addback))[lifted] <= slack[lifted] + ).all() + # Gross income never falls as the addback rises. Rounding is monotone, + # so this holds exactly. + steps = [(0, 0), (0.5, 0), (1, 0), (1, 10_000), (2, 50_000)] + scaled = calculate_variants( + [ + with_entered(households, lambda h: scale * default_addback(h) + shift) + for scale, shift in steps + ], + year, + ) + for lower, higher, step in zip(scaled, scaled[1:], steps[1:]): + assert (higher["ma_gross_income"] >= lower["ma_gross_income"]).all(), step + return floored, lifted + + +def check_entered_addback_overrides_default(households, year): + ma = in_massachusetts(households) + zero_entered, entered, entered_without_exclusion = calculate_variants( + [ + with_entered(households, lambda h: 0), + with_entered(households, lambda h: 12_345), + [{**h, "exclusion": 0, "entered": 12_345} for h in households], + ], + year, + ) + default, no_exclusion = calculate_variants( + [ + [{**h, "entered": None} for h in households], + [{**h, "exclusion": 0, "entered": None} for h in households], + ], + year, + ) + # An entered zero beside a positive exclusion counts nothing, as with no + # exclusion at all. + assert not zero_entered["ma_foreign_earned_income_exclusion_addback"].any() + for v in GROSS[1:]: + assert np.array_equal(zero_entered[v], no_exclusion[v]), v + # An entered amount does not depend on the exclusion. + for v in GROSS + TAX_UNIT_COMPONENTS: + assert np.array_equal(entered[v], entered_without_exclusion[v]), v + assert (entered["ma_foreign_earned_income_exclusion_addback"][ma] == 12_345).all() + # The default follows the exclusion. + assert np.array_equal( + default["ma_foreign_earned_income_exclusion_addback"], + np.where(ma, exclusions(households), 0), + ) + + +GRID = [ + household( + state, + status, + wages, + exclusion, + spouse_wages=40_000 if status == "JOINT" else 0, + dividends=dividends, + short_term_gains=short_term_gains, + long_term_gains=long_term_gains, + self_employment=self_employment, + public_pension=public_pension, + salt_refund=salt_refund, + entered=entered, + ) + for state in ("MA", "NY") + for status in ("SINGLE", "JOINT") + for wages in (0, 60_000) + for dividends, short_term_gains, long_term_gains in ( + (0, 0, 0), + (6_000, 3_000, 4_000), + (0, 5_000, -2_000), + ) + for self_employment in (0, -150_000) + for public_pension, salt_refund in ((0, 0), (10_000, 2_000)) + for exclusion in (0, 10_000, 130_000) + for entered in (None, 0, 25_000) +] + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_gross_income_adds_back_foreign_earnings(year): + law, before, unfloored, untouched = check_gross_income_adds_back_foreign_earnings( + GRID, year + ) + # The grid reaches households the addition changes and ones it does not. + assert (unfloored & (expected_addback(GRID) > 0)).any() + assert ( + law["ma_income_tax_before_credits"] > before["ma_income_tax_before_credits"] + ).any() + assert untouched.any() and (~untouched).any() + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_foreign_earnings_enter_part_b_only(year): + outside = check_foreign_earnings_enter_part_b_only(GRID, year) + assert outside.any() + + +@pytest.mark.parametrize("year", YEARS) +def test_ma_foreign_earnings_addition_respects_zero_floor(year): + floored, lifted = check_addition_respects_zero_floor(GRID, year) + # The grid reaches households held at the floor and ones the addition + # lifts off it. + assert floored.any() and lifted.any() + + +@pytest.mark.parametrize("year", YEARS) +def test_explicit_zero_addback_overrides_fallback(year): + check_entered_addback_overrides_default(GRID, year) + + +def test_review_example(): + # 2025, single, $32,000 of wages, $6,000 of dividends, $3,000 of + # short-term and $4,000 of long-term gains, and a $10,000 exclusion. + # Federal gross income is $45,000; Massachusetts gross income is $55,000, + # with $9,000 in Part A, $4,000 in Part C and $42,000 in Part B. Before + # this change it was $35,000, with $22,000 in Part B. + example = household( + "MA", + "SINGLE", + 32_000, + 10_000, + dividends=6_000, + short_term_gains=3_000, + long_term_gains=4_000, + ) + law, before, _, _ = check_gross_income_adds_back_foreign_earnings([example], 2025) + assert law["irs_gross_income"][0] == 45_000 + assert law["ma_gross_income"][0] == 55_000 + assert law["ma_part_a_gross_income"][0] == 9_000 + assert law["ma_part_c_gross_income"][0] == 4_000 + assert law["ma_part_b_gross_income"][0] == 42_000 + assert before["ma_gross_income"][0] == 35_000 + assert before["ma_part_b_gross_income"][0] == 22_000 + + +household_strategy = st.fixed_dictionaries( + { + "state": st.sampled_from(["MA", "MA", "MA", "NY"]), + "status": st.sampled_from(["SINGLE", "JOINT"]), + "wages": st.integers(0, 400_000), + "spouse_wages": st.one_of(st.just(0), st.integers(1, 200_000)), + "dividends": st.one_of(st.just(0), st.integers(1, 100_000)), + "short_term_gains": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "long_term_gains": st.one_of(st.just(0), st.integers(-30_000, 300_000)), + "self_employment": st.one_of(st.just(0), st.integers(-300_000, 150_000)), + "public_pension": st.one_of(st.just(0), st.integers(1, 80_000)), + "salt_refund": st.one_of(st.just(0), st.integers(1, 10_000)), + "exclusion": st.one_of(st.just(0), st.integers(1, 300_000)), + "entered": st.one_of(st.none(), st.just(0), st.integers(1, 300_000)), + } +) + +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(household_strategy, min_size=1, max_size=25), + st.sampled_from(YEARS), +) +def test_random_households_keep_the_invariants(households, year): + check_gross_income_adds_back_foreign_earnings(households, year) + check_foreign_earnings_enter_part_b_only(households, year) + check_addition_respects_zero_floor(households, year) + check_entered_addback_overrides_default(households, year) diff --git a/policyengine_us/tests/test_section_911_batch_inputs.py b/policyengine_us/tests/test_section_911_batch_inputs.py new file mode 100644 index 00000000000..7fdd239b008 --- /dev/null +++ b/policyengine_us/tests/test_section_911_batch_inputs.py @@ -0,0 +1,204 @@ +"""Public simulation batches must preserve each tax unit's input representation. + +Python coverage exercises JSON provenance and the Simulation constructor, which +the YAML runner's Core SimulationBuilder does not exercise. +""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.tests.test_foreign_earned_income_exclusion_leaves import ( + LEAVES, + YEAR, + simulation_for_tax_units, +) + +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +OUTPUTS = ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + "foreign_earned_income_exclusion_gross", + "ma_foreign_earned_income_exclusion_addback", + "ma_gross_income", +) + + +@pytest.fixture(scope="module") +def scalar_period_situation(): + # Reuse only the raw entity layout, with independent dictionaries per case. + return simulation_for_tax_units([{}, {}]).situation_input + + +@pytest.mark.parametrize("constructor_style", ["keyword", "positional"]) +@pytest.mark.parametrize("other_period", [YEAR, YEAR + 1]) +def test_scalar_leaf_period_matches_constructor_default_input_period( + scalar_period_situation, constructor_style, other_period +): + situation = deepcopy(scalar_period_situation) + situation["tax_units"]["tax_unit_0"]["foreign_earned_income_exclusion_amount"] = ( + 8_000 + ) + situation["tax_units"]["tax_unit_1"]["foreign_earned_income_exclusion_amount"] = { + other_period: 2_000 + } + + def construct(): + if constructor_style == "keyword": + return Simulation(situation=situation, default_input_period=YEAR) + # Core's default_input_period is its seventh positional argument. + return Simulation(None, None, situation, None, None, False, YEAR) + + if other_period != YEAR: + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + construct() + else: + simulation = construct() + for variable in ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + "ma_foreign_earned_income_exclusion_addback", + ): + np.testing.assert_array_equal( + simulation.calculate(variable, YEAR), [8_000, 2_000] + ) + + +@pytest.mark.parametrize( + "inputs", + [ + [ + {"foreign_earned_income_exclusion": {YEAR: 10_000}}, + { + "foreign_earned_income_exclusion_amount": {YEAR: 8_000}, + "foreign_housing_exclusion": {YEAR: 2_000}, + }, + ], + [ + {"foreign_earned_income_exclusion": {YEAR: 0}}, + {"foreign_earned_income_exclusion_amount": {YEAR: 10_000}}, + ], + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: 0}, + }, + {"foreign_earned_income_exclusion": {YEAR: 20_000}}, + ], + ], + ids=["aggregate-and-leaves", "explicit-zero-aggregate", "explicit-zero-leaf"], +) +def test_mixed_tax_unit_representations_raise_a_clear_error(inputs): + # Core fills omitted entity inputs with zeros. A global leaf switch or + # aggregate input array would silently replace one unit's own amounts. + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + simulation_for_tax_units(inputs) + + +@pytest.mark.parametrize("with_aggregate", [False, True]) +def test_temporally_mixed_leaf_components_raise_despite_matching_union_coverage( + with_aggregate, +): + inputs = [ + { + "foreign_earned_income_exclusion_amount": {YEAR: 1_000}, + "foreign_housing_exclusion": {YEAR + 1: 100}, + }, + { + "foreign_earned_income_exclusion_amount": {YEAR + 1: 2_000}, + "foreign_housing_exclusion": {YEAR: 200}, + }, + ] + if with_aggregate: + for row in inputs: + row["foreign_earned_income_exclusion"] = {YEAR: 10_000} + # Each unit supplies some leaf in both years, but the later arrays zero-fill + # the other unit's carried amount for each individual component. + with pytest.raises(ValueError, match=r"(?i)(batch|tax.unit)"): + simulation_for_tax_units(inputs) + + +def assert_batch_matches_individual_tax_units(inputs): + """Changing population composition cannot change a unit's own results.""" + batch = simulation_for_tax_units(inputs) + individuals = [simulation_for_tax_units([row]) for row in inputs] + for period in (YEAR, YEAR + 1): + for variable in OUTPUTS: + expected = np.array( + [ + simulation.calculate(variable, period)[0] + for simulation in individuals + ] + ) + np.testing.assert_array_equal(batch.calculate(variable, period), expected) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.example([0, 10_000, -5_000]) +@hypothesis.given(st.lists(st.integers(-100_000, 150_000), min_size=2, max_size=4)) +def test_uniform_legacy_batches_match_isolated_tax_units(values): + assert_batch_matches_individual_tax_units( + [{"foreign_earned_income_exclusion": {YEAR: value}} for value in values] + ) + + +@hypothesis.settings(max_examples=3, deadline=None, derandomize=True) +@hypothesis.example([(0, 0, 0, 0, 0), (8_000, 2_000, 1_000, 500, 700)]) +@hypothesis.given( + st.lists( + st.tuples( + st.integers(-100_000, 150_000), + st.integers(-100_000, 150_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + st.integers(0, 100_000), + ), + min_size=2, + max_size=4, + ) +) +def test_uniform_leaf_batches_match_isolated_tax_units(rows): + assert_batch_matches_individual_tax_units( + [{name: {YEAR: value} for name, value in zip(LEAVES, row)} for row in rows] + ) + + +def test_leaf_batches_can_supply_different_form_2555_components(): + assert_batch_matches_individual_tax_units( + [ + {"foreign_earned_income_exclusion_amount": {YEAR: 8_000}}, + {"foreign_housing_exclusion": {YEAR: 2_000}}, + {"foreign_housing_deduction": {YEAR: 0}}, + ] + ) + + +def test_none_does_not_turn_a_legacy_unit_into_a_leaf_unit(): + assert_batch_matches_individual_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: None}, + }, + {"foreign_earned_income_exclusion": {YEAR: 20_000}}, + ] + ) + + +def test_uniform_aggregate_overrides_with_explicit_zero_leaves_remain_valid(): + assert_batch_matches_individual_tax_units( + [ + { + "foreign_earned_income_exclusion": {YEAR: 10_000}, + "foreign_housing_exclusion": {YEAR: 0}, + }, + { + "foreign_earned_income_exclusion": {YEAR: 20_000}, + "foreign_housing_exclusion": {YEAR: 2_000}, + }, + ] + ) diff --git a/policyengine_us/tools/section_911.py b/policyengine_us/tools/section_911.py new file mode 100644 index 00000000000..1abe578553d --- /dev/null +++ b/policyengine_us/tools/section_911.py @@ -0,0 +1,106 @@ +"""Input provenance and election detection for Form 2555 amounts.""" + +import numpy as np +from policyengine_core.periods import period as parse_period + + +SECTION_911_LEAF_INPUTS = ( + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", +) + + +def elects_section_911_exclusion(tax_unit, period): + """Detect a section 911 election from aggregates or Form 2555 leaves. + + Section 911(a)(1) and (2) provide separate earned-income and housing + exclusion elections, so housing-only filers also elect section 911. + Positive Form 2555 amounts identify the election even when deductions or + an explicit aggregate override reduce the federal stacking amount to zero. + Schedule 8812 (Line 13 and Part II-A) and Publication 596 (Rule 5) bar + Form 2555 filers from Worksheet B, refundable CTC, and EITC. + + References: + https://www.law.cornell.edu/uscode/text/26/911#a + https://www.irs.gov/instructions/i1040s8 + https://www.irs.gov/publications/p596 + """ + return np.logical_or.reduce( + [ + tax_unit(variable, period) > 0 + for variable in ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + *SECTION_911_LEAF_INPUTS, + ) + ] + ) + + +def validate_section_911_batch_inputs(situation, default_period): + """Reject ambiguous JSON batches before Core fills omitted inputs with zeros. + + Core stores explicit-input provenance for whole arrays, not individual tax + units. With both aggregate overrides and leaves, units must have matching + input-period coverage. Values and the particular leaf components may differ. + Dataset and setter arrays already describe every entity in the array. + """ + if situation is None: + return + tax_units = situation.get("tax_units", {}) + if len(tax_units) < 2: + return + + aggregates = ( + "foreign_earned_income_exclusion", + "section_911_excluded_income", + "foreign_earned_income_exclusion_gross", + "ma_foreign_earned_income_exclusion_addback", + ) + + def input_periods(inputs, variable): + values = inputs.get(variable) + if values is None: + return frozenset() + if not isinstance(values, dict): + values = {default_period: values} + return frozenset( + parse_period(input_period) + for input_period, value in values.items() + if value is not None + ) + + signatures = [] + component_periods = {name: set() for name in SECTION_911_LEAF_INPUTS} + for inputs in tax_units.values(): + aggregate_periods = tuple(input_periods(inputs, name) for name in aggregates) + leaf_periods = frozenset() + for name in SECTION_911_LEAF_INPUTS: + supplied_periods = input_periods(inputs, name) + leaf_periods |= supplied_periods + if supplied_periods: + component_periods[name].add(supplied_periods) + signatures.append((aggregate_periods, leaf_periods)) + + has_aggregates = any(any(aggregate_periods) for aggregate_periods, _ in signatures) + has_leaves = any(leaf_periods for _, leaf_periods in signatures) + mixed_representations = ( + has_aggregates + and has_leaves + and any(signature != signatures[0] for signature in signatures[1:]) + ) + # A later array for the same leaf also zero-fills another unit's carried + # amount, even in an otherwise leaf-only batch. Absent components are safe. + mixed_leaf_periods = any( + len(coverage) > 1 for coverage in component_periods.values() + ) + if mixed_representations or mixed_leaf_periods: + raise ValueError( + "Batched tax units cannot mix section 911 aggregate or Form 2555 " + "leaf inputs with different input-period coverage. " + "Supply consistent aggregate and leaf input periods across tax " + "units, including explicit zeros, or calculate the units separately." + ) diff --git a/policyengine_us/tools/state_eitc_helpers.py b/policyengine_us/tools/state_eitc_helpers.py index caa6ad87f5f..611d135f190 100644 --- a/policyengine_us/tools/state_eitc_helpers.py +++ b/policyengine_us/tools/state_eitc_helpers.py @@ -1,6 +1,7 @@ """Shared helpers for state EITC formulas that partially track federal rules.""" from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import elects_section_911_exclusion def eitc_filing_requirement_met(tax_unit, period): @@ -21,7 +22,7 @@ def eitc_section_911_eligible(tax_unit, period): rules they name (age, SSN, separate filing), so this one still applies. """ - return tax_unit("foreign_earned_income_exclusion", period) <= 0 + return ~elects_section_911_exclusion(tax_unit, period) def eitc_filing_status_eligible( diff --git a/policyengine_us/variables/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.py b/policyengine_us/variables/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.py index c460aabcf1c..ea457d74439 100644 --- a/policyengine_us/variables/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.py +++ b/policyengine_us/variables/gov/irs/credits/ctc/refundable/refundable_ctc_barred_by_section_911_exclusion.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import elects_section_911_exclusion class refundable_ctc_barred_by_section_911_exclusion(Variable): @@ -22,9 +23,7 @@ class refundable_ctc_barred_by_section_911_exclusion(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.credits.ctc.refundable - elects_section_911_exclusion = ( - tax_unit("foreign_earned_income_exclusion", period) > 0 - ) + claims_section_911 = elects_section_911_exclusion(tax_unit, period) # Section 24(i)(1)(A) (2021) switched off all of subsection (d), # including this bar, for filers whose principal place of abode was in # the United States for more than half the year and for bona fide @@ -33,4 +32,4 @@ def formula(tax_unit, period, parameters): bar_in_force = ( p.foreign_earned_income_exclusion_bar_applies and not p.fully_refundable ) - return elects_section_911_exclusion & bar_in_force + return claims_section_911 & bar_in_force diff --git a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py index be4d58a42b0..f4be2784cbf 100644 --- a/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py +++ b/policyengine_us/variables/gov/irs/credits/earned_income/eligibility/eitc_eligible.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import elects_section_911_exclusion class eitc_eligible(Variable): @@ -22,7 +23,7 @@ def formula(tax_unit, period, parameters): ) # Section 32(c)(1)(C): "eligible individual" does not include an # individual who claims the benefits of section 911 (Form 2555). - claims_section_911 = tax_unit("foreign_earned_income_exclusion", period) > 0 + claims_section_911 = elects_section_911_exclusion(tax_unit, period) # Define eligibility before considering separate filer limitation. eligible = ( demographic_eligible 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 b217c397344..08310ede9fd 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 @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_core.parameters import get_parameter +from policyengine_core.simulations.simulation import _uprating_index_value +from policyengine_us.tools.default_uprating import DEFAULT_DOLLAR_INPUT_UPRATING class foreign_earned_income_exclusion(Variable): @@ -6,9 +9,62 @@ 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). 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." + 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. When not entered directly, it is derived as Form 2555 line 43 minus line 44, plus line 50, minus worksheet line 2b, floored at zero. An entered legacy amount, including zero, overrides that calculation. Without supplied leaf inputs, the legacy default, carry-over, and uprating behavior are preserved." definition_period = YEAR reference = [ "https://www.law.cornell.edu/uscode/text/26/911", "https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", ] + + def formula(tax_unit, period, parameters): + leaf_inputs = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", + ] + if not any( + input_period.start <= period.start + for variable in leaf_inputs + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ): + # A formula cannot also declare Core uprating. Preserve the + # old dollar input's per-capita default uprating here instead, + # including signed inputs and Core's held-flat index boundaries. + simulation = tax_unit.simulation + holder = simulation.get_holder("foreign_earned_income_exclusion") + earlier_periods = [ + known_period + for known_period in holder.get_known_periods() + if known_period.unit == period.unit + and known_period.start < period.start + ] + if not earlier_periods: + return None + latest = max(earlier_periods, key=lambda known_period: known_period.start) + index = get_parameter( + simulation.tax_benefit_system.parameters, + DEFAULT_DOLLAR_INPUT_UPRATING + "_per_capita", + ) + before = _uprating_index_value(index, latest.start) + after = _uprating_index_value(index, period.start) + factor = ( + 1 if before is None or after is None or before == 0 else after / before + ) + return holder.get_array(latest, simulation.branch_name) * factor + gross = tax_unit("foreign_earned_income_exclusion_gross", period) + allocable_deductions = tax_unit( + "foreign_earned_income_exclusion_allocable_deductions", period + ) + housing_deduction = tax_unit("foreign_housing_deduction", period) + disallowed_deductions = tax_unit( + "foreign_earned_income_exclusion_disallowed_deductions", period + ) + # Worksheet line 2c: if zero or less, enter zero. Other income and + # deduction inputs retain their existing model definitions. + return max_( + 0, gross - allocable_deductions + housing_deduction - disallowed_deductions + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py new file mode 100644 index 00000000000..c7232f0728f --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_allocable_deductions.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_allocable_deductions(Variable): + value_type = float + entity = TaxUnit + label = "Deductions allocable to excluded foreign income" + unit = USD + documentation = "Deductions allowed in figuring AGI that are allocable to excluded foreign earned income and housing amounts: Form 2555 line 44, summed across both spouses on a joint return. These reduce line 45 but not the gross exclusions on line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#d_6", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py new file mode 100644 index 00000000000..cd63b609b35 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_amount.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_amount(Variable): + value_type = float + entity = TaxUnit + label = "Foreign earned income exclusion under section 911(a)(1)" + unit = USD + documentation = "Foreign earned income excluded under 26 U.S.C. 911(a)(1): Form 2555 line 42, summed across both spouses on a joint return. This excludes the line 36 housing exclusion and the line 50 housing deduction." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a_1", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py new file mode 100644 index 00000000000..d291514081d --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_disallowed_deductions.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_disallowed_deductions(Variable): + value_type = float + entity = TaxUnit + label = "Foreign income deductions disallowed on the federal tax worksheet" + unit = USD + documentation = "Itemized deductions or exclusions disallowed because they relate to excluded foreign income: Foreign Earned Income Tax Worksheet line 2b. This adjustment is separate from Form 2555 line 44 and reduces the federal stacking amount, not Form 2555 line 43. This input only supplies the federal rate worksheet adjustment; other income and deduction inputs retain their existing model definitions." + definition_period = YEAR + reference = [ + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py new file mode 100644 index 00000000000..2f7e8359848 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_earned_income_exclusion_gross.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class foreign_earned_income_exclusion_gross(Variable): + value_type = float + entity = TaxUnit + label = "Gross foreign earned income and housing exclusions" + unit = USD + documentation = ( + "Gross exclusions under 26 U.S.C. 911(a): Form 2555 line 43, equal " + "to the line 36 housing exclusion plus the line 42 foreign earned " + "income exclusion, summed across both spouses on a joint return. " + "This is before line 44 allocable deductions and excludes the line " + "50 housing deduction and federal tax worksheet line 2b adjustments." + ) + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] + adds = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py new file mode 100644 index 00000000000..cc7550eef7c --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_deduction.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_housing_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Foreign housing deduction" + unit = USD + documentation = "Foreign housing deduction under 26 U.S.C. 911(c)(4): Form 2555 line 50, summed across both spouses on a joint return and reported on Schedule 1 line 24j. This supplies federal stacking and MAGI adjustments, outside the gross exclusions on line 43. It does not automatically reduce AGI: other income and deduction inputs retain their existing reported/net definitions." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#c_4", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py new file mode 100644 index 00000000000..bb2dd81bb18 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/foreign_housing_exclusion.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class foreign_housing_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "Foreign housing exclusion under section 911(a)(2)" + unit = USD + documentation = "Housing amount excluded under 26 U.S.C. 911(a)(2): Form 2555 line 36, summed across both spouses on a joint return. This is separate from the line 50 housing deduction." + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911#a_2", + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py index c7fecc900da..4f53757cb87 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/section_911_excluded_income.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import SECTION_911_LEAF_INPUTS class section_911_excluded_income(Variable): @@ -10,12 +11,12 @@ class section_911_excluded_income(Variable): "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." + "return). Modified adjusted gross incomes add this amount back, before " + "the federal stacking worksheet subtracts disallowed deductions and " + "floors its result at zero. Applicable Form 2555 leaves determine this " + "amount, preserving a negative line 45. Without those leaves, the " + "legacy foreign_earned_income_exclusion amount is the fallback; it " + "cannot reconstruct the pre-worksheet amount." ) definition_period = YEAR reference = ( @@ -28,5 +29,24 @@ class section_911_excluded_income(Variable): # 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", + # Publication 54's deduction example 4 reports a negative line 45. + "https://www.irs.gov/publications/p54", ) - adds = ["foreign_earned_income_exclusion"] + + def formula(tax_unit, period, parameters): + has_leaf_inputs = any( + input_period.start <= period.start + for variable in SECTION_911_LEAF_INPUTS + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ) + if not has_leaf_inputs: + return tax_unit("foreign_earned_income_exclusion", period) + + gross = tax_unit("foreign_earned_income_exclusion_gross", period) + allocable_deductions = tax_unit( + "foreign_earned_income_exclusion_allocable_deductions", period + ) + housing_deduction = tax_unit("foreign_housing_deduction", period) + return gross - allocable_deductions + housing_deduction diff --git a/policyengine_us/variables/gov/irs/tax_unit_is_filer.py b/policyengine_us/variables/gov/irs/tax_unit_is_filer.py index 8395e8c7b47..d67b3372a6b 100644 --- a/policyengine_us/variables/gov/irs/tax_unit_is_filer.py +++ b/policyengine_us/variables/gov/irs/tax_unit_is_filer.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import elects_section_911_exclusion class tax_unit_is_filer(Variable): @@ -54,7 +55,7 @@ def formula(tax_unit, period, parameters): # so those credits cannot be its reason to file. A claimant under the # filing threshold need not file in a later year of a continuing # election, so the propensity to file for a tax benefit decides. - claims_section_911 = tax_unit("foreign_earned_income_exclusion", period) > 0 + claims_section_911 = elects_section_911_exclusion(tax_unit, period) files_for_section_911 = claims_section_911 & would_file_for_credits return required | files_for_credits | files_for_section_911 | files_voluntarily diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py new file mode 100644 index 00000000000..9eca8685b1e --- /dev/null +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_foreign_earned_income_exclusion_addback.py @@ -0,0 +1,63 @@ +from policyengine_us.model_api import * + + +class ma_foreign_earned_income_exclusion_addback(Variable): + value_type = float + entity = TaxUnit + label = "MA addition of foreign earned income excluded under section 911" + unit = USD + documentation = ( + "Foreign earned income and housing amounts excluded from federal gross " + "income under 26 U.S.C. 911(a), which Massachusetts adds to gross " + "income: Form 2555 line 43 (the line 36 housing exclusion plus the " + "line 42 foreign earned income exclusion, from both spouses' forms on a " + "joint return). It is not reduced by the line 44 deductions allocable " + "to the excluded income or by Foreign Earned Income Tax Worksheet line " + "2b, and it does not include the line 50 housing deduction, which is a " + "deduction rather than an exclusion. Other income inputs must already " + "exclude this amount. When any section 911 leaf input is supplied for " + "the year or carried from an earlier year, the default is " + "foreign_earned_income_exclusion_gross, floored at zero. For legacy " + "callers supplying no applicable leaves, the default " + "remains foreign_earned_income_exclusion, the worksheet line 2c " + "stacking amount, floored at zero. That legacy approximation equals " + "line 43 when Form 2555 lines 44 and 50 and worksheet line 2b are " + "zero, or when line 50 equals line 44 plus line 2b. An entered " + "Massachusetts amount, including zero, replaces either default." + ) + definition_period = YEAR + reference = ( + # c.62 s.2(a)(1)(C): "Earned income from foreign sources excluded + # under section nine hundred and eleven of the Code" is added to + # federal gross income. + "https://malegislature.gov/Laws/GeneralLaws/PartI/TitleIX/Chapter62/Section2", + # Form 1 line 3: compensation excluded under IRC s.911 must be + # included in line 3 for Massachusetts tax purposes. + "https://www.mass.gov/doc/2025-form-1-instructions/download#page=10", + # Form 2555 Part VIII, line 43: lines 36 and 42, before line 44. + "https://www.irs.gov/pub/irs-prior/f2555--2025.pdf#page=3", + # Foreign Earned Income Tax Worksheet lines 2a to 2c. + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=37", + ) + defined_for = StateCode.MA + + def formula(tax_unit, period, parameters): + leaf_inputs = [ + "foreign_earned_income_exclusion_amount", + "foreign_housing_exclusion", + "foreign_earned_income_exclusion_allocable_deductions", + "foreign_housing_deduction", + "foreign_earned_income_exclusion_disallowed_deductions", + ] + # Core's input export helper tracks supplied values by year and + # branch. Cached default zeros must not replace the legacy fallback; + # explicitly supplied zeros must. Available since core 3.32.8. + if any( + input_period.start <= period.start + for variable in leaf_inputs + for input_period in tax_unit.simulation._get_exportable_input_periods( + variable, include_computed_variables=False + ) + ): + return max_(0, tax_unit("foreign_earned_income_exclusion_gross", period)) + return max_(0, tax_unit("foreign_earned_income_exclusion", period)) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py index b1e586f6a30..02d305f5263 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income.py @@ -20,8 +20,15 @@ def formula(tax_unit, period, parameters): federal_gross_income = add(tax_unit, period, ["irs_gross_income"]) # Add back lines 6/7 losses dropped by irs_gross_income. loss_adjustment = tax_unit("ma_gross_income_loss_adjustment", period) - # Exclude foreign earned income, Social Security, state/local tax refunds, - # and contributory public pensions. + # Under M.G.L. c. 62 § 2(a)(1)(C), earned income from foreign sources + # excluded under section 911 of the Code is added to MA gross income. + # It is neither interest, dividends nor capital gains, so the Part B + # residual taxes it under § 2(b)(2). + foreign_earned_income = tax_unit( + "ma_foreign_earned_income_exclusion_addback", period + ) + # Exclude Social Security, state/local tax refunds, and contributory + # public pensions. # Under M.G.L. c. 62 § 2(a)(2)(E), contributory pensions from the US, # Massachusetts, or reciprocal states are excluded from MA gross income. # Noncontributory or non-reciprocal public pensions are not exempt. @@ -30,7 +37,6 @@ def formula(tax_unit, period, parameters): # income. Dependents' income is not in it (irs_gross_income excludes # it; they report it on their own return), so deduct only the head's # and spouse's amounts. - foreign_earned_income = tax_unit("foreign_earned_income_exclusion", period) social_security_in_agi = add(tax_unit, period, ["taxable_social_security"]) salt_refund_income = tax_unit_non_dep_sum( "salt_refund_income", tax_unit, period @@ -38,10 +44,8 @@ def formula(tax_unit, period, parameters): public_pension = tax_unit_non_dep_sum( "taxable_public_pension_income", tax_unit, period ) - deductions = ( - foreign_earned_income - + social_security_in_agi - + salt_refund_income - + public_pension + deductions = social_security_in_agi + salt_refund_income + public_pension + return max_( + 0, + federal_gross_income + loss_adjustment + foreign_earned_income - deductions, ) - return max_(0, federal_gross_income + loss_adjustment - deductions) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py index 0dd34bc357e..5704b213abf 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/credits/eitc/nj_childless_eitc_age_eligible.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.section_911 import elects_section_911_exclusion class nj_childless_eitc_age_eligible(Variable): @@ -33,7 +34,7 @@ def formula(tax_unit, period, parameters): # Section 32(c)(1)(C): a filer who claims the benefits of section 911 # fails a federal qualification other than age. - claims_section_911 = tax_unit("foreign_earned_income_exclusion", period) > 0 + claims_section_911 = elects_section_911_exclusion(tax_unit, period) return ( ~separate