diff --git a/changelog.d/feie-tax-stacking-911f.fixed.md b/changelog.d/feie-tax-stacking-911f.fixed.md new file mode 100644 index 00000000000..bb15f583342 --- /dev/null +++ b/changelog.d/feie-tax-stacking-911f.fixed.md @@ -0,0 +1 @@ +Tax the income of filers who exclude foreign earned income at the rates it would face on top of the excluded amount, in the regular tax, the capital gains rates and the alternative minimum tax (26 U.S.C. 911(f), the Foreign Earned Income Tax Worksheet). diff --git a/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py b/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py index b8f26eef620..effd9e5564b 100644 --- a/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py +++ b/policyengine_us/reforms/additional_tax_bracket/additional_tax_bracket_reform.py @@ -1,6 +1,12 @@ from policyengine_us.model_api import * from policyengine_core.periods import period as period_ from policyengine_core.periods import instant +from policyengine_us.variables.gov.irs.tax.federal_income.before_credits.tax_at_main_rates import ( + tax_at_main_rates, +) +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) def create_additional_tax_bracket() -> Reform: @@ -15,14 +21,19 @@ class regular_tax_before_credits(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) - dwks1 = tax_unit("taxable_income", period) + # As in the baseline formula, a Form 2555 filer stacks taxable + # income on the excluded amount (26 U.S.C. 911(f)). + dwks1 = tax_unit("taxable_income_plus_section_911_exclusion", period) + worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("taxable_income", period) + ) dwks16 = min_(p.capital_gains.thresholds["1"][filing_status], dwks1) dwks17 = min_(tax_unit("dwks14", period), dwks16) dwks20 = dwks16 - dwks17 lowest_rate_tax = p.capital_gains.rates["1"] * dwks20 # Break in worksheet lines - dwks13 = tax_unit("dwks13", period) + dwks13 = worksheet.line_13 dwks21 = min_(dwks1, dwks13) dwks22 = dwks20 dwks23 = max_(0, dwks21 - dwks22) @@ -36,11 +47,8 @@ def formula(tax_unit, period, parameters): dwks31 = dwks21 - dwks30 dwks32 = p.capital_gains.rates["3"] * dwks31 # Break in worksheet lines - dwks33 = min_( - tax_unit("dwks09", period), - add(tax_unit, period, ["unrecaptured_section_1250_gain"]), - ) - dwks10 = tax_unit("dwks10", period) + dwks33 = min_(worksheet.line_9, worksheet.unrecaptured_section_1250_gain) + dwks10 = worksheet.line_10 dwks34 = dwks10 + dwks19 dwks36 = max_(0, dwks34 - dwks1) dwks37 = max_(0, dwks33 - dwks36) @@ -54,25 +62,18 @@ def formula(tax_unit, period, parameters): dwks41 = p.amt.brackets.rates[-1] * dwks40 # Compute regular tax using bracket rates and thresholds + # The shared schedule clamps inverted brackets as in the + # baseline formulas (#9084). reg_taxinc = max_(0, dwks19) - p_reform = parameters(period).gov.contrib.additional_tax_bracket - bracket_tops = p_reform.bracket.thresholds - bracket_rates = p_reform.bracket.rates - reg_tax = 0 - bracket_bottom = 0 - for i in range(1, len(list(bracket_rates.__iter__())) + 1): - b = str(i) - # Clamp as in the baseline formulas (#9084): an - # inverted bracket contributes zero width. - bracket_top = max_(bracket_bottom, bracket_tops[b][filing_status]) - reg_tax += bracket_rates[b] * amount_between( - reg_taxinc, bracket_bottom, bracket_top - ) - bracket_bottom = bracket_top + bracket = parameters(period).gov.contrib.additional_tax_bracket.bracket + reg_tax = tax_at_main_rates(reg_taxinc, filing_status, bracket) # Return to worksheet lines dwks42 = reg_tax dwks43 = dwks29 + dwks32 + dwks38 + dwks41 + dwks42 + lowest_rate_tax + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) + dwks43 = where(excluded > 0, max_(0, dwks43 - tax_on_excluded), dwks43) dwks44 = tax_unit("income_tax_main_rates", period) dwks45 = min_(dwks43, dwks44) return where(tax_unit("has_qdiv_or_ltcg", period), dwks45, dwks44) @@ -82,33 +83,31 @@ class income_tax_main_rates(Variable): entity = TaxUnit definition_period = YEAR label = "Income tax main rates" - reference = "https://www.law.cornell.edu/uscode/text/26/1" + reference = [ + "https://www.law.cornell.edu/uscode/text/26/1", + "https://www.law.cornell.edu/uscode/text/26/911#f_1_A", + ] unit = USD def formula(tax_unit, period, parameters): - # compute taxable income that is taxed at the main rates - full_taxable_income = tax_unit("taxable_income", period) + # compute taxable income that is taxed at the main rates; as in + # the baseline formula, a taxpayer excluding foreign earned + # income adds the excluded amount back (26 U.S.C. 911(f)(1)(A)) + full_taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) cg_exclusion = tax_unit( "capital_gains_excluded_from_taxable_income", period ) taxinc = max_(0, full_taxable_income - cg_exclusion) # compute tax using bracket rates and thresholds - p = parameters(period).gov.contrib.additional_tax_bracket - bracket_tops = p.bracket.thresholds - bracket_rates = p.bracket.rates + bracket = parameters(period).gov.contrib.additional_tax_bracket.bracket filing_status = tax_unit("filing_status", period) - tax = 0 - bracket_bottom = 0 - for i in range(1, len(list(bracket_rates.__iter__())) + 1): - b = str(i) - # Clamp as in the baseline formulas (#9084): an - # inverted bracket contributes zero width. - bracket_top = max_(bracket_bottom, bracket_tops[b][filing_status]) - tax += bracket_rates[b] * amount_between( - taxinc, bracket_bottom, bracket_top - ) - bracket_bottom = bracket_top - return tax + tax = tax_at_main_rates(taxinc, filing_status, bracket) + # less the tax on the excluded amount alone + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) + return where(excluded > 0, max_(0, tax - tax_on_excluded), tax) class reform(Reform): def apply(self): diff --git a/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py b/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py index 84d56813bcd..b2d5e63903f 100644 --- a/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py +++ b/policyengine_us/reforms/biden/budget_2025/capital_gains_tax_increase.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.section_911_net_capital_gain_other_than_dividends import ( + section_911_net_capital_gain_other_than_dividends, +) def create_capital_gains_tax_increase() -> Reform: @@ -10,10 +13,16 @@ class capital_gains_tax(Variable): definition_period = YEAR def formula(tax_unit, period, parameters): - net_cg = tax_unit("net_capital_gain", period) - taxable_income = tax_unit("taxable_income", period) + # As in the baseline formula, a taxpayer excluding foreign earned + # income applies the rates to taxable income plus the excluded + # amount, with gains reduced by any capital gain excess + # (26 U.S.C. 911(f)). + net_cg = tax_unit("section_911_net_capital_gain", period) + taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) adjusted_net_cg = min_( - tax_unit("adjusted_net_capital_gain", period), + tax_unit("section_911_adjusted_net_capital_gain", period), taxable_income, ) # ANCG is referred to in all cases as ANCG or taxable income if less. @@ -42,7 +51,12 @@ def formula(tax_unit, period, parameters): p_reform = parameters(period).gov.contrib.biden.budget_2025.capital_gains income_threshold = p_reform.income_threshold[filing_status] - excess_income = max_(0, taxable_income - income_threshold) + # The threshold tests the filer's own income, which leaves out an + # amount excluded under section 911, so it uses taxable income + # before the excluded amount is added back; the rates still stack. + excess_income = max_( + 0, tax_unit("taxable_income", period) - income_threshold + ) cg_in_top_bracket = min_(adjusted_net_cg, excess_income) @@ -90,14 +104,18 @@ def formula(tax_unit, period, parameters): ) unrecaptured_s_1250_gain = tax_unit( - "unrecaptured_section_1250_gain", period + "section_911_unrecaptured_section_1250_gain", period ) - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) - + # Net capital gain determined without regard to section 1(h)(11), + # reduced first by any capital gain excess (26 U.S.C. + # 911(f)(2)(A)(i)). + net_cg_other_than_dividends = ( + section_911_net_capital_gain_other_than_dividends(tax_unit, period) + ) max_taxable_unrecaptured_gain = min_( unrecaptured_s_1250_gain, - max_(0, net_cg - qualified_dividends), + net_cg_other_than_dividends, ) unrecaptured_gain_deduction = max_( non_cg_taxable_income + net_cg - taxable_income, @@ -113,8 +131,7 @@ def formula(tax_unit, period, parameters): ) remaining_cg_tax = ( - tax_unit("capital_gains_28_percent_rate_gain", period) - * cg.other_cg_rate + tax_unit("section_911_28_percent_rate_gain", period) * cg.other_cg_rate ) return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax diff --git a/policyengine_us/reforms/crfb/non_refundable_ss_credit.py b/policyengine_us/reforms/crfb/non_refundable_ss_credit.py index 66753340c08..df1bf668e00 100644 --- a/policyengine_us/reforms/crfb/non_refundable_ss_credit.py +++ b/policyengine_us/reforms/crfb/non_refundable_ss_credit.py @@ -38,8 +38,12 @@ class highest_tax_rate(Variable): unit = "/1" def formula(tax_unit, period, parameters): - # compute taxable income that is taxed at the main rates - full_taxable_income = tax_unit("taxable_income", period) + # compute taxable income that is taxed at the main rates; a + # taxpayer excluding foreign earned income adds the excluded + # amount back (26 U.S.C. 911(f)(1)(A)) + full_taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) cg_exclusion = tax_unit( "capital_gains_excluded_from_taxable_income", period ) diff --git a/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py b/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py index 587bad8b18b..c51f1339e83 100644 --- a/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py +++ b/policyengine_us/reforms/harris/capital_gains/harris_capital_gains.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.section_911_net_capital_gain_other_than_dividends import ( + section_911_net_capital_gain_other_than_dividends, +) def create_harris_capital_gains() -> Reform: @@ -10,10 +13,16 @@ class capital_gains_tax(Variable): definition_period = YEAR def formula(tax_unit, period, parameters): - net_cg = tax_unit("net_capital_gain", period) - taxable_income = tax_unit("taxable_income", period) + # As in the baseline formula, a taxpayer excluding foreign earned + # income applies the rates to taxable income plus the excluded + # amount, with gains reduced by any capital gain excess + # (26 U.S.C. 911(f)). + net_cg = tax_unit("section_911_net_capital_gain", period) + taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) adjusted_net_cg = min_( - tax_unit("adjusted_net_capital_gain", period), + tax_unit("section_911_adjusted_net_capital_gain", period), taxable_income, ) # ANCG is referred to in all cases as ANCG or taxable income if less. @@ -85,12 +94,17 @@ def formula(tax_unit, period, parameters): ) unrecaptured_s_1250_gain = tax_unit( - "unrecaptured_section_1250_gain", period + "section_911_unrecaptured_section_1250_gain", period + ) + # Net capital gain determined without regard to section 1(h)(11), + # reduced first by any capital gain excess (26 U.S.C. + # 911(f)(2)(A)(i)). + net_cg_other_than_dividends = ( + section_911_net_capital_gain_other_than_dividends(tax_unit, period) ) - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) max_taxable_unrecaptured_gain = min_( unrecaptured_s_1250_gain, - max_(0, net_cg - qualified_dividends), + net_cg_other_than_dividends, ) unrecaptured_gain_deduction = max_( non_cg_taxable_income + net_cg - taxable_income, @@ -106,8 +120,7 @@ def formula(tax_unit, period, parameters): ) remaining_cg_tax = ( - tax_unit("capital_gains_28_percent_rate_gain", period) - * cg.other_cg_rate + tax_unit("section_911_28_percent_rate_gain", period) * cg.other_cg_rate ) return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax diff --git a/policyengine_us/tests/policy/baseline/contrib/biden/budget_2025/capital_gains_tax_increase.yaml b/policyengine_us/tests/policy/baseline/contrib/biden/budget_2025/capital_gains_tax_increase.yaml index 2c28da6024a..dca62226284 100644 --- a/policyengine_us/tests/policy/baseline/contrib/biden/budget_2025/capital_gains_tax_increase.yaml +++ b/policyengine_us/tests/policy/baseline/contrib/biden/budget_2025/capital_gains_tax_increase.yaml @@ -21,7 +21,34 @@ output: # First 100,000 taxed at 20% # Last 100,000 taxed at 37% - capital_gains_tax: 57_000 # 15,000 + 37,000 + capital_gains_tax: 57_000 # 20,000 + 37,000 + +- name: A section 911 exclusion stacks the rates but not the $1 million test + period: 2024 + reforms: policyengine_us.reforms.biden.budget_2025.capital_gains_tax_increase.capital_gains_tax_increase + input: + gov.contrib.biden.budget_2025.capital_gains.active: true + people: + head: + age: 36 + qualified_dividend_income: 0 + long_term_capital_gains: 80_000 + spouse: + age: 36 + qualified_dividend_income: 120_000 + long_term_capital_gains: 0 + tax_units: + tax_unit: + members: [head, spouse] + taxable_income: 1_100_000 + filing_status: JOINT + foreign_earned_income_exclusion: 300_000 + output: + # The threshold is tested on taxable income without the excluded amount, + # so as in the first case 100,000 of the gains is over it: 100,000 at + # 20% and 100,000 at 37%. Testing it on taxable income plus the excluded + # amount would put all 200,000 at 37%. + capital_gains_tax: 57_000 # 20,000 + 37,000 - name: Reform not applied period: 2024 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/integration.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/integration.yaml index 181319ef027..bb79e5e6b30 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/ctc/integration.yaml @@ -1,3 +1,48 @@ +# Section 911 filer with positive taxable income: the CTC's tax limit sees the +# tax figured on the Foreign Earned Income Tax Worksheet (26 U.S.C. 911(f)). +- name: Head of household with two children, positive taxable income and a section 911 exclusion in 2025. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + # Wages included in gross income; $40,000 of foreign wages is + # excluded on Form 2555. + employment_income: 60_000 + child1: + age: 5 + child2: + age: 8 + tax_units: + tax_unit: + members: [parent, child1, child2] + foreign_earned_income_exclusion: 40_000 + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + filing_status: HEAD_OF_HOUSEHOLD + # $60,000 less the $23,625 head of household standard deduction. + taxable_income: 36_375 + # Foreign Earned Income Tax Worksheet (Form 1040 line 16): line 3 = + # $76,375, line 4 = $9,977.50 (Tax Table $9,978), line 5 = tax on + # $40,000 = $4,460 (Tax Table $4,463), line 6 = $5,517.50 (Tax Table + # $5,515). Taxed from the bottom bracket it would be $4,025. + income_tax_before_credits: 5_517.50 + eitc: 0 + # Schedule 8812 line 12: 2 x $2,200. + ctc: 4_400 + # Credit Limit Worksheet A sees the stacked tax, which covers the whole + # credit, so nothing is left to refund (section 24(d)(3) would bar the + # refund in any case). + ctc_limiting_tax_liability: 5_517.50 + refundable_ctc: 0 + non_refundable_ctc: 4_400 + income_tax_before_refundable_credits: 1_117.50 + income_tax: 1_117.50 + - name: Single parent of 10 children aged 6 to 17, $200k income during ARP. period: 2021 absolute_error_margin: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml index f09f13e8a81..e39db40df01 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.yaml @@ -152,3 +152,92 @@ state_code: NM output: alternative_minimum_tax: 0 + +# 26 U.S.C. 911(f)(1)(B) and the 2025 Form 6251 instructions, Foreign Earned +# Income Tax Worksheet—Line 7: line 4 is the tax on Form 6251 line 6 plus the +# excluded amount, line 5 the tax at the AMT rates on the excluded amount, +# line 6 the difference. +- name: A Form 2555 filer subtracts the AMT on the excluded amount + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 150_000 # worksheet line 1 + foreign_earned_income_exclusion: 130_000 # worksheet line 2c + amt_part_iii_required: false + foreign_tax_credit: 0 + regular_tax_before_credits: 30_000 + form_4972_lumpsum_distributions: 0 + capital_gains_tax: 0 + output: + # Line 3 = 280,000. Line 4 = 280,000 x 28% - 4,782 = 73,618. Line 5 = + # 130,000 x 26% = 33,800. Line 6 = 39,818, which goes on Form 6251 line 7. + # Form 6251 line 11 = 39,818 - 30,000. + amt_base_tax: 73_618 + alternative_minimum_tax: 9_818 + +- name: The same filer without the exclusion + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 150_000 + foreign_earned_income_exclusion: 0 + amt_part_iii_required: false + foreign_tax_credit: 0 + regular_tax_before_credits: 30_000 + form_4972_lumpsum_distributions: 0 + capital_gains_tax: 0 + output: + # Form 6251 line 7 = 150,000 x 26% = 39,000. + alternative_minimum_tax: 9_000 + +- name: A separate filer's excluded amount crosses the halved breakpoint + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SEPARATE + amt_income_less_exemptions: 50_000 + foreign_earned_income_exclusion: 130_000 + amt_part_iii_required: false + foreign_tax_credit: 0 + regular_tax_before_credits: 0 + form_4972_lumpsum_distributions: 0 + capital_gains_tax: 0 + output: + # Line 3 = 180,000. Line 4 = 180,000 x 28% - 2,391 = 48,009. Line 5 = + # 130,000 x 28% - 2,391 = 34,009. Line 6 = 14,000. + alternative_minimum_tax: 14_000 + +- name: With Part III the smaller tax on line 3 is reduced by the tax on the excluded amount + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 211_900 + foreign_earned_income_exclusion: 100_000 + amt_part_iii_required: true + amt_tax_including_cg: 57_785 + foreign_tax_credit: 0 + regular_tax_before_credits: 0 + form_4972_lumpsum_distributions: 0 + capital_gains_tax: 42_637.50 + output: + # Form 6251 line 39 = 311,900 x 28% - 4,782 = 82,550, line 38 = 57,785, + # line 40 = 57,785. Worksheet line 5 = 100,000 x 26% = 26,000, line 6 = + # 31,785. That is below the regular tax of 42,637.50. + alternative_minimum_tax: 0 + +- name: No AMT stacking when Form 6251 line 6 is zero + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 0 + foreign_earned_income_exclusion: 130_000 + amt_part_iii_required: false + foreign_tax_credit: 0 + regular_tax_before_credits: 0 + form_4972_lumpsum_distributions: 0 + capital_gains_tax: 0 + output: + alternative_minimum_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml index 6759e34ef7c..47c75977ab5 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.yaml @@ -156,3 +156,44 @@ # 0%: $89,250 (joint); 15%: $310,750 * 15% = $46,612.50; # 20%: $0. amt_tax_including_cg: 46_612.50 + +# 26 U.S.C. 911(f)(2)(B) and the 2025 Form 6251 instructions, Part III, +# "Form 2555": lines 13 to 15 are figured after reducing the gains by the AMT +# capital gain excess (the gains less Form 6251 line 6), while lines 20 and 27 +# keep the regular tax amounts. +- name: A Form 2555 filer with an AMT capital gain excess + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + long_term_capital_gains: 300_000 + taxable_income: 284_250 + amt_income_less_exemptions: 211_900 # Form 6251 line 6 + foreign_earned_income_exclusion: 100_000 + output: + # Regular tax capital gain excess: 300,000 - 284,250. + section_911_capital_gain_excess: 15_750 + # AMT capital gain excess: 300,000 - 211,900. + amt_section_911_capital_gain_excess: 88_100 + # Line 12 = 311,900 (worksheet line 3). Lines 13 and 15 = 211,900. + # Line 17 = 100,000, line 18 = 26,000. Line 20 = 100,000 (the regular + # tax worksheet's ordinary income: 384,250 - 284,250), so line 21 = 0. + # Line 22 = 211,900, line 23 = 0, line 29 = 533,400 - 100,000 = 433,400, + # line 30 = 211,900, line 31 = 31,785, line 33 = 0. Line 38 = 57,785. + dwks14: 100_000 + amt_tax_including_cg: 57_785 + +- name: The same gains without the exclusion + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + long_term_capital_gains: 300_000 + taxable_income: 284_250 + amt_income_less_exemptions: 211_900 + foreign_earned_income_exclusion: 0 + output: + # Line 12 = 211,900, line 13 = 300,000, line 16 = 211,900, line 17 = 0. + # Line 20 = 0, line 21 = 48,350, line 22 = 211,900, line 23 = 48,350, + # line 24 = 163,550, line 30 = 163,550, line 31 = 24,532.50. + amt_tax_including_cg: 24_532.50 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_base_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_base_tax.yaml index 80951a6307b..0c1559c03e8 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_base_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_base_tax.yaml @@ -21,3 +21,22 @@ amt_income_less_exemptions: 200_000 output: amt_base_tax: 52_000 + +- name: A Form 2555 filer's tax on line 3 of the Form 6251 Foreign Earned Income Tax Worksheet + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 150_000 + foreign_earned_income_exclusion: 130_000 + output: + # Worksheet line 4, "All others": 280,000 x 28% - 4,782. + amt_base_tax: 73_618 + +- name: The exclusion is not added when Form 6251 line 6 is zero + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 0 + foreign_earned_income_exclusion: 130_000 + output: + amt_base_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.yaml index 5a977f910aa..0f35b68a550 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.yaml @@ -21,3 +21,13 @@ amt_income_less_exemptions: 600_000 output: amt_higher_base_tax: 110_292 + +- name: A Form 2555 filer's base is line 3 of the Form 6251 Foreign Earned Income Tax Worksheet + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 150_000 + foreign_earned_income_exclusion: 130_000 + output: + # Line 3 = 280,000; 28% of the 40,900 above 239,100. + amt_higher_base_tax: 11_452 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.yaml index 031f588ebba..6f6a97cd261 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.yaml @@ -21,3 +21,13 @@ amt_income_less_exemptions: 400_000 output: amt_lower_base_tax: 53_586 + +- name: A Form 2555 filer's base is line 3 of the Form 6251 Foreign Earned Income Tax Worksheet + period: 2025 + input: + filing_status: SINGLE + amt_income_less_exemptions: 150_000 + foreign_earned_income_exclusion: 130_000 + output: + # Line 3 = 280,000; 26% of the first 239,100. + amt_lower_base_tax: 62_166 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml index fef8e374234..eea12a96d21 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.yaml @@ -7,3 +7,31 @@ income_tax_main_rates: 1_000 output: regular_tax_before_credits: 1_000 + +- name: A Form 2555 filer without gains pays the stacked main-rates tax + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 36_375 + foreign_earned_income_exclusion: 40_000 + output: + # Foreign Earned Income Tax Worksheet: tax on 76,375 (9,977.50) less tax + # on 40,000 (4,460.00). + regular_tax_before_credits: 5_517.50 + +- name: A Form 2555 filer with a capital gain excess + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 34_250 + long_term_capital_gains: 50_000 + foreign_earned_income_exclusion: 100_000 + output: + # Line 1 = 134,250 and the gains are cut to 34,250, so the ordinary + # income is the 100,000 excluded and its tax cancels against line 5 of + # the Foreign Earned Income Tax Worksheet. The 5,137.50 of capital gains + # tax is counted separately in capital_gains_tax. + regular_tax_before_credits: 0 + capital_gains_tax: 5_137.50 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.yaml new file mode 100644 index 00000000000..3c591066f52 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.yaml @@ -0,0 +1,98 @@ +# 26 U.S.C. 911(f)(1)(A) and the 2025 Form 1040 instructions, Foreign Earned +# Income Tax Worksheet—Line 16: line 4 is the tax on taxable income plus the +# excluded amount, line 5 the tax on the excluded amount, line 6 the +# difference. Tax amounts are from the 2025 Tax Table and Tax Computation +# Worksheet in the same instructions. +- name: Head of household, both lookups in the 2025 Tax Table + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 30_050 # line 1 + foreign_earned_income_exclusion: 30_025 # line 2c + capital_gains_excluded_from_taxable_income: 0 + output: + # Line 3: 60,075. Line 4: Tax Table row 60,050-60,100, head of household, + # 6,869. Line 5: row 30,000-30,050, 3,263. Line 6: 3,606. Both rows' + # midpoints are the amounts taxed, so the table is exact here. + income_tax_main_rates: 3_606 + +- name: The same filer without the exclusion + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 30_050 + foreign_earned_income_exclusion: 0 + capital_gains_excluded_from_taxable_income: 0 + output: + # 1,700 + 12% of 13,050. (The Tax Table row 30,050-30,100 shows 3,269, + # the tax on its midpoint 30,075.) + income_tax_main_rates: 3_266 + +- name: The review example, 60,000 of wages and 40,000 excluded + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: HEAD_OF_HOUSEHOLD + taxable_income: 36_375 + foreign_earned_income_exclusion: 40_000 + capital_gains_excluded_from_taxable_income: 0 + output: + # Line 3: 76,375. Line 4: 9,977.50 (Tax Table 9,978). Line 5: 4,460.00 + # (Tax Table row 40,000-40,050: 4,463, the tax on 40,025). Line 6: + # 5,517.50 (Tax Table 5,515). Without stacking the tax was 4,025. + income_tax_main_rates: 5_517.50 + +- name: Joint filers on the Tax Computation Worksheet + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: JOINT + taxable_income: 218_500 + foreign_earned_income_exclusion: 260_000 + capital_gains_excluded_from_taxable_income: 0 + output: + # Line 3: 478,500. Line 4: 478,500 x 32% - 45,874 = 107,246. Line 5: + # 260,000 x 24% - 14,306 = 48,094. Line 6: 59,152. Without the exclusion: + # 218,500 x 24% - 14,306 = 38,134. + income_tax_main_rates: 59_152 + +- name: No taxable income, no tax + period: 2025 + input: + filing_status: SINGLE + taxable_income: 0 + foreign_earned_income_exclusion: 130_000 + capital_gains_excluded_from_taxable_income: 0 + output: + # "If Form 1040 or 1040-SR, line 15, is zero, don't complete this + # worksheet." + income_tax_main_rates: 0 + +- name: Capital gains come out of line 3 before the main rates + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 40_000 + foreign_earned_income_exclusion: 30_000 + capital_gains_excluded_from_taxable_income: 30_000 + output: + # Tax on 70,000 - 30,000 = 40,000 (4,561.50) less tax on 30,000 + # (3,361.50): the 10,000 of ordinary income is taxed at 12%, not 10%. + income_tax_main_rates: 1_200 + +- name: In 2018 the same rule applies + absolute_error_margin: 0.01 + period: 2018 + input: + filing_status: SINGLE + taxable_income: 20_000 + foreign_earned_income_exclusion: 30_000 + capital_gains_excluded_from_taxable_income: 0 + output: + # 2018 single schedule: 10% to 9,525, 12% to 38,700, 22% to 82,500. + # Tax on 50,000: 952.50 + 3,501 + 2,486 = 6,939.50. Tax on 30,000: + # 952.50 + 2,457 = 3,409.50. Difference: 3,530. + income_tax_main_rates: 3_530 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.yaml new file mode 100644 index 00000000000..149e1fb3c5d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.yaml @@ -0,0 +1,38 @@ +# 26 U.S.C. 1(h)(1)(A), applied for a taxpayer excluding foreign earned income +# to taxable income plus the excluded amount, with the gains reduced by any +# capital gain excess (26 U.S.C. 911(f)). +- name: Gains within taxable income are excluded in full + period: 2025 + input: + filing_status: SINGLE + taxable_income: 40_000 + net_capital_gain: 30_000 + adjusted_net_capital_gain: 30_000 + foreign_earned_income_exclusion: 0 + output: + capital_gains_excluded_from_taxable_income: 30_000 + +- name: An exclusion leaves gains within taxable income excluded in full + period: 2025 + input: + filing_status: SINGLE + taxable_income: 40_000 + net_capital_gain: 30_000 + adjusted_net_capital_gain: 30_000 + foreign_earned_income_exclusion: 30_000 + output: + # Qualified Dividends and Capital Gain Tax Worksheet: line 1 = 70,000, + # line 4 = 30,000, line 5 = 40,000. + capital_gains_excluded_from_taxable_income: 30_000 + +- name: A capital gain excess caps the excluded gains at taxable income + period: 2025 + input: + filing_status: SINGLE + taxable_income: 34_250 + net_capital_gain: 50_000 + foreign_earned_income_exclusion: 100_000 + output: + # Excess 15,750. Worksheet: line 1 = 134,250, line 3 = 34,250, line 5 = + # 100,000, so the whole excluded amount stays in the main-rates base. + capital_gains_excluded_from_taxable_income: 34_250 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml new file mode 100644 index 00000000000..bf32cbc136d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.yaml @@ -0,0 +1,93 @@ +# 26 U.S.C. 1(h)(1), applied for a taxpayer excluding foreign earned income to +# taxable income plus the excluded amount, with the gains reduced by any +# capital gain excess (26 U.S.C. 911(f)(2)(A)). Line references are to the +# 2025 Qualified Dividends and Capital Gain Tax Worksheet and Schedule D Tax +# Worksheet as a Form 2555 filer completes them. +- name: Gains in the 0% bracket without an exclusion + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 40_000 + net_capital_gain: 30_000 + adjusted_net_capital_gain: 30_000 + foreign_earned_income_exclusion: 0 + output: + # Line 5 = 10,000, line 9 = min(40,000, 48,350) - 10,000 = 30,000 at 0%. + capital_gains_tax: 0 + +- name: The excluded amount uses up the 0% bracket + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 40_000 + net_capital_gain: 30_000 + adjusted_net_capital_gain: 30_000 + foreign_earned_income_exclusion: 30_000 + output: + # Line 1 = 70,000, line 4 = 30,000, line 5 = 40,000, line 7 = 48,350, + # line 9 = 8,350 at 0%, line 17 = 21,650, line 18 = 3,247.50. + capital_gains_tax: 3_247.50 + +- name: A capital gain excess taxes only the gains within taxable income + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 34_250 + net_capital_gain: 50_000 + foreign_earned_income_exclusion: 100_000 + output: + # Excess 15,750. Line 1 = 134,250, line 3 = 34,250, line 5 = 100,000, + # line 9 = 0, line 17 = 34,250, line 18 = 5,137.50. + capital_gains_tax: 5_137.50 + +- name: A capital gain excess that reaches the qualified dividends + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 14_250 + qualified_dividend_income: 20_000 + net_capital_gain: 30_000 + foreign_earned_income_exclusion: 100_000 + output: + # Excess 15,750: line 3 falls from 10,000 to 0 and line 2 from 20,000 to + # 14,250. Line 1 = 114,250, line 5 = 100,000, line 17 = 14,250. + section_911_qualified_dividend_income: 14_250 + capital_gains_tax: 2_137.50 + +- name: A capital gain excess with 25 and 28 percent gains + absolute_error_margin: 0.01 + period: 2025 + input: + filing_status: SINGLE + taxable_income: 85_000 + qualified_dividend_income: 10_000 + net_capital_gain: 100_000 + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + foreign_earned_income_exclusion: 200_000 + output: + # Excess 15,000. Schedule D Tax Worksheet: line 1 = 285,000, line 6 = + # 10,000, line 9 = 75,000, line 10 = 85,000, Schedule D line 18 = 0 and + # line 19 = 15,000, line 13 = 70,000, line 21 = 200,000, line 30 = + # 70,000, line 31 = 10,500, line 39 = 15,000, line 40 = 3,750, line 42 = + # 0. Line 44 (tax on 200,000) equals worksheet line 5 of the Foreign + # Earned Income Tax Worksheet, so line 6 is 14,250. + section_911_capital_gain_excess: 15_000 + capital_gains_excluded_from_taxable_income: 85_000 + income_tax_main_rates: 0 + capital_gains_tax: 14_250 + +- name: No taxable income, no capital gains tax + period: 2025 + input: + filing_status: SINGLE + taxable_income: 0 + qualified_dividend_income: 3_000 + net_capital_gain: 3_000 + foreign_earned_income_exclusion: 130_000 + output: + capital_gains_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml new file mode 100644 index 00000000000..2f9bd240d75 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks14.yaml @@ -0,0 +1,64 @@ +# Schedule D Tax Worksheet line 14 (Qualified Dividends and Capital Gain Tax +# Worksheet line 5): line 1 less line 13. A Form 2555 filer enters line 3 of +# the Foreign Earned Income Tax Worksheet on line 1 and refigures line 13 for +# any capital gain excess. +- name: Line 1 less line 13 without an exclusion + period: 2025 + input: + taxable_income: 40_000 + long_term_capital_gains: 30_000 + foreign_earned_income_exclusion: 0 + output: + dwks14: 10_000 + +- name: An exclusion raises line 1 + period: 2025 + input: + taxable_income: 40_000 + long_term_capital_gains: 30_000 + foreign_earned_income_exclusion: 30_000 + output: + dwks14: 40_000 + +- name: A capital gain excess leaves the excluded amount on line 14 + period: 2025 + input: + taxable_income: 34_250 + long_term_capital_gains: 50_000 + foreign_earned_income_exclusion: 100_000 + output: + # Excess 15,750: line 13 = 34,250 and line 1 = 134,250. + section_911_capital_gain_excess: 15_750 + dwks14: 100_000 + +- name: With no taxable income line 14 is the excluded amount + period: 2025 + input: + taxable_income: 0 + qualified_dividend_income: 3_000 + foreign_earned_income_exclusion: 130_000 + output: + # Form 6251 line 20 takes this amount. The Form 1040 worksheet is not + # completed when taxable income is zero, but 26 U.S.C. 911(f)(2)(B)(ii) + # points line 20 at the regular tax computation of (f)(1)(A)(i), on + # taxable income plus the excluded amount; the Form 6251 instructions for + # line 20 likewise use line 3 of the Foreign Earned Income Tax Worksheet + # when neither capital gains worksheet was completed. + dwks14: 130_000 + +# Synthetic inputs: on a real return worksheet line 10 and net capital gain +# are the same amount. They are set apart here only to show which one each +# excess reads. +- name: The worksheet lines read the excess from line 10, not net_capital_gain + period: 2025 + input: + taxable_income: 34_250 + long_term_capital_gains: 50_000 # worksheet lines 9, 10 and 13 + net_capital_gain: 45_000 + foreign_earned_income_exclusion: 100_000 + output: + # The section 1(h) excess: 45,000 - 34,250. + section_911_capital_gain_excess: 10_750 + # The worksheet excess: 50,000 - 34,250 = 15,750, which brings line 13 + # to 34,250 and leaves the 100,000 excluded on line 14. + dwks14: 100_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks19.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks19.yaml new file mode 100644 index 00000000000..2b4a3a4ec3d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/dwks19.yaml @@ -0,0 +1,33 @@ +# The Schedule D Tax Worksheet amount taxed at ordinary rates (line 21 of the +# 2025 worksheet). A Form 2555 filer enters line 3 of the Foreign Earned +# Income Tax Worksheet on line 1 and refigures line 10 for any capital gain +# excess. +- name: Ordinary income without an exclusion + period: 2025 + input: + filing_status: SINGLE + taxable_income: 140_000 + long_term_capital_gains: 30_000 + foreign_earned_income_exclusion: 0 + output: + dwks19: 110_000 + +- name: An exclusion adds to the ordinary income + period: 2025 + input: + filing_status: SINGLE + taxable_income: 140_000 + long_term_capital_gains: 30_000 + foreign_earned_income_exclusion: 100_000 + output: + dwks19: 210_000 + +- name: A capital gain excess leaves the excluded amount as ordinary income + period: 2025 + input: + filing_status: SINGLE + taxable_income: 34_250 + long_term_capital_gains: 50_000 + foreign_earned_income_exclusion: 100_000 + output: + dwks19: 100_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.yaml new file mode 100644 index 00000000000..a91824c968b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.yaml @@ -0,0 +1,25 @@ +# 26 U.S.C. 911(f)(1)(B)(i); 2025 Form 6251 instructions, Foreign Earned +# Income Tax Worksheet—Line 7, lines 1 to 3. +- name: Line 3 is Form 6251 line 6 plus the excluded amount + period: 2025 + input: + amt_income_less_exemptions: 150_000 # line 1 + foreign_earned_income_exclusion: 130_000 # line 2c + output: + amt_income_less_exemptions_plus_section_911_exclusion: 280_000 # line 3 + +- name: The worksheet is skipped when Form 6251 line 6 is zero + period: 2025 + input: + amt_income_less_exemptions: 0 + foreign_earned_income_exclusion: 130_000 + output: + amt_income_less_exemptions_plus_section_911_exclusion: 0 + +- name: No exclusion leaves the taxable excess unchanged + period: 2025 + input: + amt_income_less_exemptions: 150_000 + foreign_earned_income_exclusion: 0 + output: + amt_income_less_exemptions_plus_section_911_exclusion: 150_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml new file mode 100644 index 00000000000..eee942d4233 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.yaml @@ -0,0 +1,42 @@ +# 26 U.S.C. 911(f)(2)(B)(i); 2025 Form 6251 instructions, Part III, Form 2555: +# "subtract Form 6251, line 6, from line 4 of your AMT Qualified Dividends and +# Capital Gain Tax Worksheet or line 10 of your AMT Schedule D Tax Worksheet". +- name: Worksheet line 10 above the taxable excess is the AMT excess + period: 2025 + input: + foreign_earned_income_exclusion: 100_000 + long_term_capital_gains: 300_000 # Schedule D Tax Worksheet line 10 + amt_income_less_exemptions: 211_900 + output: + amt_section_911_capital_gain_excess: 88_100 + +- name: No AMT excess when the taxable excess covers worksheet line 10 + period: 2025 + input: + foreign_earned_income_exclusion: 100_000 + long_term_capital_gains: 300_000 # Schedule D Tax Worksheet line 10 + amt_income_less_exemptions: 400_000 + output: + amt_section_911_capital_gain_excess: 0 + +- name: No AMT excess without an exclusion + period: 2025 + input: + foreign_earned_income_exclusion: 0 + long_term_capital_gains: 300_000 # Schedule D Tax Worksheet line 10 + amt_income_less_exemptions: 211_900 + output: + amt_section_911_capital_gain_excess: 0 + +# Synthetic inputs: on a real return worksheet line 10 and net capital gain +# are the same amount. They are set apart here only to show which one the +# AMT excess reads. +- name: The AMT excess reads worksheet line 10, not net_capital_gain + period: 2025 + input: + foreign_earned_income_exclusion: 100_000 + dwks10: 300_000 + net_capital_gain: 250_000 + amt_income_less_exemptions: 211_900 + output: + amt_section_911_capital_gain_excess: 88_100 # 300,000 - 211,900 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/integration.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/integration.yaml new file mode 100644 index 00000000000..57bacbd51de --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/integration.yaml @@ -0,0 +1,288 @@ +# Household cases for 26 U.S.C. 911(f): a taxpayer who excludes foreign earned +# income pays tax on the rest at the rates it would face on top of the +# excluded amount. Line numbers are from the 2025 Instructions for Form 1040 +# (Foreign Earned Income Tax Worksheet—Line 16, page 37; Qualified Dividends +# and Capital Gain Tax Worksheet—Line 16, page 38) and the 2025 Form 6251 and +# its instructions. Tax amounts were checked against the 2025 Tax Table and +# Tax Computation Worksheet in the same instructions. Income amounts are the +# amounts left in gross income after the exclusion. + +- name: Head of household, both worksheet lookups in the Tax Table + absolute_error_margin: 0.01 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 53_675 + child1: + age: 10 + child2: + age: 8 + tax_units: + tax_unit: + members: [parent, child1, child2] + foreign_earned_income_exclusion: 30_025 + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + # Form 1040 line 15: 53,675 - 23,625 standard deduction. + taxable_income: 30_050 # worksheet line 1 + taxable_income_plus_section_911_exclusion: 60_075 # line 3 (line 2c is 30,025) + # Line 4: Tax Table, at least 60,050 but less than 60,100, head of + # household: 6,869. Line 5: at least 30,000 but less than 30,050: 3,263. + # Line 6: 3,606. Taxed from the bottom bracket, 30,050 would bear 3,266. + income_tax_main_rates: 3_606 + capital_gains_tax: 0 + alternative_minimum_tax: 0 + income_tax_before_credits: 3_606 + # The child tax credit's tax limit sees the stacked tax. + ctc_limiting_tax_liability: 3_606 + +- name: The same household without the exclusion + absolute_error_margin: 0.01 + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 53_675 + child1: + age: 10 + child2: + age: 8 + tax_units: + tax_unit: + members: [parent, child1, child2] + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + taxable_income: 30_050 + taxable_income_plus_section_911_exclusion: 30_050 + # 1,700 + 12% of 13,050. + income_tax_main_rates: 3_266 + income_tax_before_credits: 3_266 + ctc_limiting_tax_liability: 3_266 + +- name: Head of household with 60,000 of wages and 40,000 excluded + 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: 40_000 + households: + household: + members: [parent, child1, child2] + state_code: TX + output: + taxable_income: 36_375 # line 1 + taxable_income_plus_section_911_exclusion: 76_375 # line 3 + # Line 4: Tax Table 9,978 (9,977.50 exactly). Line 5: Tax Table 4,463, + # the tax on the middle of the 40,000-40,050 row (4,460.00 on 40,000 + # exactly). Line 6: 5,515 by the table, 5,517.50 exactly. Taxed from the + # bottom bracket, 36,375 would bear 4,025. + income_tax_main_rates: 5_517.50 + income_tax_before_credits: 5_517.50 + ctc_limiting_tax_liability: 5_517.50 + +- name: Joint filers above 100,000 use the Tax Computation Worksheet + absolute_error_margin: 0.01 + period: 2025 + input: + people: + spouse1: + age: 45 + employment_income: 250_000 + spouse2: + age: 45 + tax_units: + tax_unit: + members: [spouse1, spouse2] + # Both spouses' Form 2555 amounts. + foreign_earned_income_exclusion: 260_000 + households: + household: + members: [spouse1, spouse2] + state_code: TX + output: + # 250,000 - 31,500 standard deduction. + taxable_income: 218_500 # line 1 + taxable_income_plus_section_911_exclusion: 478_500 # line 3 + # Line 4: 478,500 x 32% - 45,874 = 107,246. Line 5: 260,000 x 24% - + # 14,306 = 48,094. Line 6: 59,152. Without the exclusion: 218,500 x 24% + # - 14,306 = 38,134. + income_tax_main_rates: 59_152 + capital_gains_tax: 0 + +- name: Capital gains within taxable income lose the 0% bracket to the excluded amount + absolute_error_margin: 0.01 + period: 2025 + input: + people: + person: + age: 40 + employment_income: 25_750 + long_term_capital_gains: 30_000 + tax_units: + tax_unit: + members: [person] + foreign_earned_income_exclusion: 30_000 + households: + household: + members: [person] + state_code: TX + output: + # 55,750 - 15,750 standard deduction. + taxable_income: 40_000 # line 1 + # The gains (30,000) do not exceed taxable income: no capital gain excess. + section_911_capital_gain_excess: 0 + # Qualified Dividends and Capital Gain Tax Worksheet: line 1 = 70,000, + # line 4 = 30,000, line 5 = 40,000, line 7 = 48,350, line 9 = 8,350 (0%), + # line 17 = 21,650, line 18 = 3,247.50, line 22 = 4,561.50 (tax on + # 40,000), line 23 = 7,809. Foreign Earned Income Tax Worksheet line 5 = + # 3,361.50 (tax on 30,000), line 6 = 4,447.50. Without the exclusion the + # tax is 1,000: 10% of 10,000, and the gains at 0%. + income_tax_main_rates: 1_200 + capital_gains_tax: 3_247.50 + income_tax_before_credits: 4_447.50 + +- name: A capital gain excess keeps the excluded amount at ordinary rates + absolute_error_margin: 0.01 + period: 2025 + input: + people: + person: + age: 40 + long_term_capital_gains: 50_000 + tax_units: + tax_unit: + members: [person] + foreign_earned_income_exclusion: 100_000 + households: + household: + members: [person] + state_code: TX + output: + # 50,000 - 15,750 standard deduction. + taxable_income: 34_250 # line 1 + # Worksheet footnote: line 4 of the capital gains worksheet (50,000) less + # Form 1040 line 15 (34,250). + section_911_capital_gain_excess: 15_750 + section_911_net_capital_gain: 34_250 + # Second capital gains worksheet: line 1 = 134,250, line 3 = 34,250, + # line 5 = 100,000, line 9 = 0, line 17 = 34,250, line 18 = 5,137.50, + # line 22 = tax on 100,000, which equals line 5 of the Foreign Earned + # Income Tax Worksheet. Line 6 = 5,137.50. Without the exclusion the + # 34,250 of gains falls in the 0% bracket and the tax is 0. + income_tax_main_rates: 0 + capital_gains_tax: 5_137.50 + income_tax_before_credits: 5_137.50 + +- name: A capital gain excess that reaches the qualified dividends + absolute_error_margin: 0.01 + period: 2025 + input: + people: + person: + age: 40 + qualified_dividend_income: 20_000 + long_term_capital_gains: 10_000 + tax_units: + tax_unit: + members: [person] + foreign_earned_income_exclusion: 100_000 + households: + household: + members: [person] + state_code: TX + output: + taxable_income: 14_250 # line 1 + section_911_capital_gain_excess: 15_750 # 30,000 - 14,250 + # Modification 1 takes line 3 from 10,000 to 0; modification 2 takes + # line 2 from 20,000 to 14,250. + section_911_qualified_dividend_income: 14_250 + section_911_net_capital_gain: 14_250 + # Line 1 = 114,250, line 5 = 100,000, line 17 = 14,250 at 15%. + income_tax_main_rates: 0 + capital_gains_tax: 2_137.50 + +- name: No taxable income, no tax, whatever is excluded + period: 2025 + input: + people: + person: + age: 40 + employment_income: 10_000 + qualified_dividend_income: 3_000 + tax_units: + tax_unit: + members: [person] + foreign_earned_income_exclusion: 130_000 + households: + household: + members: [person] + state_code: TX + output: + # "If Form 1040 or 1040-SR, line 15, is zero, don't complete this + # worksheet." + taxable_income: 0 + income_tax_main_rates: 0 + capital_gains_tax: 0 + alternative_minimum_tax: 0 + income_tax_before_credits: 0 + +- name: Alternative minimum tax with an AMT capital gain excess + absolute_error_margin: 0.5 + period: 2025 + input: + people: + person: + age: 40 + qualified_dividend_income: 25_000 + long_term_capital_gains: 900_000 + tax_units: + tax_unit: + members: [person] + foreign_earned_income_exclusion: 130_000 + households: + household: + members: [person] + state_code: TX + output: + taxable_income: 909_250 # 925,000 - 15,750 + # Regular tax. Capital gain excess 15,750, so line 3 of the capital + # gains worksheet is 884,250. Line 1 = 1,039,250, line 4 = 909,250, + # line 5 = 130,000, line 9 = 0, line 17 = 533,400 - 130,000 = 403,400, + # line 18 = 60,510, line 20 = 505,850, line 21 = 101,170, line 22 = tax + # on 130,000, which equals line 5 of the Foreign Earned Income Tax + # Worksheet. Line 6 = 161,680. + section_911_capital_gain_excess: 15_750 + income_tax_main_rates: 0 + capital_gains_tax: 161_680 + # Form 6251. AMTI 925,000; exemption 88,100 - 25% x (925,000 - 626,350) + # = 13,437.50; line 6 = 911,562.50. AMT capital gain excess: 925,000 - + # 911,562.50. + amt_income_less_exemptions: 911_562.50 + amt_section_911_capital_gain_excess: 13_437.50 + # Worksheet line 3 = 1,041,562.50 = Form 6251 line 12. Lines 13 and 15 = + # 911,562.50, line 17 = 130,000, line 18 = 33,800. Line 20 = 130,000, + # line 21 = 0, line 22 = 911,562.50, line 29 = 403,400, line 30 = + # 403,400, line 31 = 60,510, line 33 = 508,162.50, line 34 = 101,632.50, + # line 38 = 195,942.50. Worksheet line 5 = 130,000 x 26% = 33,800, line + # 6 = 162,142.50. Form 6251 line 11 = 162,142.50 - 161,680. + alternative_minimum_tax: 462.50 + income_tax_before_credits: 162_142.50 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.yaml new file mode 100644 index 00000000000..7b4218f9b25 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.yaml @@ -0,0 +1,27 @@ +# 26 U.S.C. 911(f)(2)(A)(iii) with 1(h)(4): the capital gain excess is added +# to the losses subtracted in figuring 28-percent rate gain. Worksheet +# footnote, modification 3: "Reduce (but not below zero) the amount on your +# Schedule D, line 18, by your capital gain excess." +- name: The excess reduces 28-percent rate gain + period: 2025 + input: + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 4_000 + output: + section_911_28_percent_rate_gain: 6_000 + +- name: 28-percent rate gain is not reduced below zero + period: 2025 + input: + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 15_000 + output: + section_911_28_percent_rate_gain: 0 + +- name: No excess leaves 28-percent rate gain unchanged + period: 2025 + input: + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 0 + output: + section_911_28_percent_rate_gain: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.yaml new file mode 100644 index 00000000000..dc90e6c1c9f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.yaml @@ -0,0 +1,40 @@ +# 26 U.S.C. 911(f)(2)(A)(iii) with 1(h)(3): adjusted net capital gain is net +# capital gain other than qualified dividends, reduced (not below zero) by +# unrecaptured section 1250 gain and 28-percent rate gain, plus qualified +# dividends, each after the capital gain excess. +- name: The excess comes off the 28-percent and 1250 gains first + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 100_000 # 90,000 of gain plus the dividends + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 15_000 + output: + section_911_net_capital_gain: 85_000 + section_911_qualified_dividend_income: 10_000 + section_911_28_percent_rate_gain: 0 + section_911_unrecaptured_section_1250_gain: 15_000 + # (85,000 - 10,000) - (15,000 + 0) + 10,000. Schedule D Tax Worksheet: + # line 9 = 75,000, line 10 = 85,000, line 11 = 15,000, line 13 = 70,000. + section_911_adjusted_net_capital_gain: 70_000 + +- name: Without 25 or 28 percent gains the excess comes off the adjusted gain + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 40_000 + section_911_capital_gain_excess: 35_000 + output: + section_911_net_capital_gain: 5_000 + section_911_qualified_dividend_income: 5_000 + section_911_adjusted_net_capital_gain: 5_000 + +- name: No excess leaves adjusted net capital gain unchanged + period: 2025 + input: + adjusted_net_capital_gain: 70_000 + net_capital_gain: 100_000 + section_911_capital_gain_excess: 0 + output: + section_911_adjusted_net_capital_gain: 70_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.yaml new file mode 100644 index 00000000000..c7990ea39ad --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.yaml @@ -0,0 +1,40 @@ +# 26 U.S.C. 911(f)(2)(A): "the taxpayer's net capital gain exceeds taxable +# income (hereafter in this subparagraph referred to as the capital gain +# excess)". Worksheet footnote: line 4 of the Qualified Dividends and Capital +# Gain Tax Worksheet (line 10 of the Schedule D Tax Worksheet) less Form 1040 +# line 15. +- name: Net capital gain above taxable income is the excess + period: 2025 + input: + foreign_earned_income_exclusion: 100_000 + net_capital_gain: 50_000 + taxable_income: 34_250 + output: + section_911_capital_gain_excess: 15_750 + +- name: No excess when taxable income covers the gain + period: 2025 + input: + foreign_earned_income_exclusion: 100_000 + net_capital_gain: 30_000 + taxable_income: 40_000 + output: + section_911_capital_gain_excess: 0 + +- name: No excess without an exclusion + period: 2025 + input: + foreign_earned_income_exclusion: 0 + net_capital_gain: 50_000 + taxable_income: 34_250 + output: + section_911_capital_gain_excess: 0 + +- name: With no taxable income the whole gain is the excess + period: 2025 + input: + foreign_earned_income_exclusion: 130_000 + net_capital_gain: 3_000 + taxable_income: 0 + output: + section_911_capital_gain_excess: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.yaml new file mode 100644 index 00000000000..346fdbb8f09 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.yaml @@ -0,0 +1,28 @@ +# 26 U.S.C. 911(f)(2)(A)(i)-(ii): the gain other than qualified dividends is +# reduced by the capital gain excess, then the qualified dividends by the rest. +# Together they leave net capital gain equal to taxable income. +- name: The excess reduces net capital gain to taxable income + period: 2025 + input: + net_capital_gain: 50_000 + section_911_capital_gain_excess: 15_750 + output: + section_911_net_capital_gain: 34_250 + +- name: No excess leaves net capital gain unchanged + period: 2025 + input: + net_capital_gain: 50_000 + section_911_capital_gain_excess: 0 + output: + section_911_net_capital_gain: 50_000 + +- name: From the inputs, no taxable income leaves no net capital gain + period: 2025 + input: + foreign_earned_income_exclusion: 130_000 + net_capital_gain: 3_000 + taxable_income: 0 + output: + section_911_capital_gain_excess: 3_000 + section_911_net_capital_gain: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml new file mode 100644 index 00000000000..c4ca9605f72 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.yaml @@ -0,0 +1,40 @@ +# 26 U.S.C. 911(f)(2)(A)(ii): "the taxpayer's qualified dividend income shall +# be reduced by so much of such capital gain excess as exceeds the taxpayer's +# net capital gain (determined without regard to section 1(h)(11) and the +# reduction under clause (i))". Worksheet footnote, modification 2. +- name: An excess within the other gain leaves the dividends alone + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 40_000 # 30,000 of gain plus the dividends + section_911_capital_gain_excess: 20_000 + output: + section_911_qualified_dividend_income: 10_000 + +- name: An excess above the other gain reduces the dividends by the rest + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 40_000 + section_911_capital_gain_excess: 35_000 + output: + # 35,000 - 30,000 = 5,000 of the excess is left for the dividends. + section_911_qualified_dividend_income: 5_000 + +- name: Dividends are not reduced below zero + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 40_000 + section_911_capital_gain_excess: 45_000 + output: + section_911_qualified_dividend_income: 0 + +- name: No excess leaves the dividends unchanged + period: 2025 + input: + qualified_dividend_income: 10_000 + net_capital_gain: 40_000 + section_911_capital_gain_excess: 0 + output: + section_911_qualified_dividend_income: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.yaml new file mode 100644 index 00000000000..907a753abc1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.yaml @@ -0,0 +1,50 @@ +# 26 U.S.C. 911(f)(2)(A)(iii) with 1(h)(6)(A): unrecaptured section 1250 gain +# is reduced by the excess of the section 1(h)(4)(B) losses, now including the +# capital gain excess, over the section 1(h)(4)(A) gains. Worksheet footnote, +# modification 4: the excess is a loss on line 16 of the Unrecaptured Section +# 1250 Gain Worksheet, whose line 17 nets it against the 28% rate gain. +- name: An excess within the 28-percent rate gain leaves the 1250 gain alone + period: 2025 + input: + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 4_000 + output: + section_911_unrecaptured_section_1250_gain: 20_000 + +- name: An excess above the 28-percent rate gain reduces the 1250 gain by the rest + period: 2025 + input: + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 15_000 + output: + # Worksheet line 17: 15,000 - 10,000 = 5,000. Line 18: 20,000 - 5,000. + section_911_unrecaptured_section_1250_gain: 15_000 + +- name: Without 28-percent rate gain the excess comes off the 1250 gain + period: 2025 + input: + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 0 + section_911_capital_gain_excess: 15_000 + output: + section_911_unrecaptured_section_1250_gain: 5_000 + +- name: The 1250 gain is not reduced below zero + period: 2025 + input: + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 40_000 + output: + section_911_unrecaptured_section_1250_gain: 0 + +- name: No excess leaves the 1250 gain unchanged + period: 2025 + input: + unrecaptured_section_1250_gain: 20_000 + capital_gains_28_percent_rate_gain: 10_000 + section_911_capital_gain_excess: 0 + output: + section_911_unrecaptured_section_1250_gain: 20_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.yaml new file mode 100644 index 00000000000..cecf3f73b82 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.yaml @@ -0,0 +1,35 @@ +# 26 U.S.C. 911(f)(1)(A)(i); 2025 Form 1040 instructions, Foreign Earned Income +# Tax Worksheet—Line 16, lines 1 to 3. +- name: Line 3 is taxable income plus the excluded amount + period: 2025 + input: + taxable_income: 36_375 # line 1 + foreign_earned_income_exclusion: 40_000 # line 2c + output: + taxable_income_plus_section_911_exclusion: 76_375 # line 3 + +- name: No exclusion leaves taxable income unchanged + period: 2025 + input: + taxable_income: 36_375 + foreign_earned_income_exclusion: 0 + output: + taxable_income_plus_section_911_exclusion: 36_375 + +- name: Line 2c is not less than zero + period: 2025 + input: + taxable_income: 36_375 + foreign_earned_income_exclusion: -5_000 + output: + taxable_income_plus_section_911_exclusion: 36_375 + +- name: With no taxable income line 3 is the excluded amount + period: 2025 + input: + taxable_income: 0 + foreign_earned_income_exclusion: 130_000 + output: + # Form 6251 line 20 uses this amount when neither capital gains worksheet + # was completed for the regular tax. + taxable_income_plus_section_911_exclusion: 130_000 diff --git a/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py b/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py index afb6bc635a2..995a588fc11 100644 --- a/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py +++ b/policyengine_us/tests/test_negative_main_rates_inverted_brackets.py @@ -11,13 +11,14 @@ above bracket 6 top $305,875), worth a flat −$82,486 per married-filing-separately filer under TCJA-expiration counterfactuals. -Three code sites carry the same loop and all must clamp together: +Three code sites run the schedule and all must clamp together: `income_tax_main_rates`, the AMT regular-tax worksheet (`regular_tax_before_credits`, taken when the unit has qualified dividends or long-term gains), and the `additional_tax_bracket` contrib reform's copies of both. Clamping only the first diverges the AMT comparator from the corrected main-rates tax and manufactures phantom -AMT for any affected filer with $1 of preferential income. +AMT for any affected filer with $1 of preferential income. They now +share one loop, `tax_at_main_rates`, which carries the clamp. Note the fixture detail that makes these tests real: situation values must be period-keyed (`{"2026": ...}`). An undated `filing_status` diff --git a/policyengine_us/tests/test_section_911_tax_stacking.py b/policyengine_us/tests/test_section_911_tax_stacking.py new file mode 100644 index 00000000000..c11f6664919 --- /dev/null +++ b/policyengine_us/tests/test_section_911_tax_stacking.py @@ -0,0 +1,782 @@ +"""Property and differential tests for the section 911(f) tax computation. + +26 U.S.C. 911(f)(1): if any amount is excluded from gross income under +section 911(a), "the tax imposed by section 1 for such taxable year shall +be equal to the excess (if any) of (i) the tax which would be imposed by +section 1 for such taxable year if the taxpayer's taxable income were +increased by the amount excluded under subsection (a) for such taxable +year, over (ii) the tax which would be imposed by section 1 for such +taxable year if the taxpayer's taxable income were equal to the amount +excluded". Subparagraph (B) applies the same rule to the alternative +minimum tax, and paragraph (2) keeps capital gains from exceeding taxable +income (the "capital gain excess"). + +Two kinds of test: + +- Differential. The 2025 IRS worksheets are transcribed below line by line + (Foreign Earned Income Tax Worksheet, Qualified Dividends and Capital + Gain Tax Worksheet, and Form 6251 with its own Foreign Earned Income Tax + Worksheet and Part III) and compared with the model, household by + household. The model reaches the same tax by a different route: the + section 1(h) formulas in `capital_gains_excluded_from_taxable_income`, + `income_tax_main_rates` and `capital_gains_tax`. +- Properties that hold for every household: with nothing excluded, every + amount that section 911(f) replaces is unchanged, bit for bit; the stacked + tax is never below the unstacked tax; zero taxable income gives zero + regular tax; and the tax never falls as the excluded amount rises. + +Households live in Texas; some itemize. + +Three limits of the model set the scope of these tests, and none comes +from section 911: + +- `capital_gains_tax` omits the section 1(h)(1) cap at the tax on all + taxable income at ordinary rates (worksheet line 25, "the smaller of line + 23 or line 24"). The cap binds only where the 15% rate applies inside the + 12% bracket, a band of $100 to $250 of income. The model is therefore + compared with worksheet line 23, and checked to be within $7.50 of line + 25. +- `capital_gains_tax` taxes 28-percent rate gain in full even when it + exceeds taxable income, so the households here have no 28-percent rate + or unrecaptured section 1250 gain. YAML unit tests cover how the capital + gain excess reduces those two amounts. +- `amt_tax_including_cg` applies the 26%/28% breakpoint of other filers to + married-filing-separately filers on Form 6251 line 18, so the AMT of + separate filers with dividends or gains is not compared. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +# Hypothesis is a dev extra; skip rather than fail collection without it. +hypothesis = pytest.importorskip("hypothesis") +st = pytest.importorskip("hypothesis.strategies") + +# --------------------------------------------------------------------------- +# The 2025 IRS worksheets, transcribed. Dollar amounts are from the 2025 +# Instructions for Form 1040 (Tax Table, Tax Computation Worksheet and the +# worksheets on pages 37 and 38) and the 2025 Form 6251 and its instructions. +# --------------------------------------------------------------------------- + +INF = float("inf") +# Top of each rate bracket. +TAX_RATE_SCHEDULE_2025 = { + "SINGLE": [ + (11_925, 0.10), + (48_475, 0.12), + (103_350, 0.22), + (197_300, 0.24), + (250_525, 0.32), + (626_350, 0.35), + (INF, 0.37), + ], + "JOINT": [ + (23_850, 0.10), + (96_950, 0.12), + (206_700, 0.22), + (394_600, 0.24), + (501_050, 0.32), + (751_600, 0.35), + (INF, 0.37), + ], + "SEPARATE": [ + (11_925, 0.10), + (48_475, 0.12), + (103_350, 0.22), + (197_300, 0.24), + (250_525, 0.32), + (375_800, 0.35), + (INF, 0.37), + ], + "HEAD_OF_HOUSEHOLD": [ + (17_000, 0.10), + (64_850, 0.12), + (103_350, 0.22), + (197_300, 0.24), + (250_500, 0.32), + (626_350, 0.35), + (INF, 0.37), + ], +} +# Qualified Dividends and Capital Gain Tax Worksheet, lines 6 and 13. +ZERO_RATE_AMOUNT_2025 = { + "SINGLE": 48_350, + "JOINT": 96_700, + "SEPARATE": 48_350, + "HEAD_OF_HOUSEHOLD": 64_750, +} +FIFTEEN_PERCENT_RATE_AMOUNT_2025 = { + "SINGLE": 533_400, + "JOINT": 600_050, + "SEPARATE": 300_000, + "HEAD_OF_HOUSEHOLD": 566_700, +} + + +def tax_rate_schedule(amount, status): + """Tax on an amount at the ordinary rates (Tax Computation Worksheet).""" + tax, bottom = 0.0, 0.0 + for top, rate in TAX_RATE_SCHEDULE_2025[status]: + if amount > bottom: + tax += rate * (min(amount, top) - bottom) + bottom = top + return tax + + +def qualified_dividends_and_capital_gain_tax_worksheet(line_1, line_2, line_3, status): + """2025 Form 1040 instructions, page 38. Returns lines 5, 23 and 25.""" + line_4 = line_2 + line_3 + line_5 = max(0, line_1 - line_4) + line_6 = ZERO_RATE_AMOUNT_2025[status] + line_7 = min(line_1, line_6) + line_8 = min(line_5, line_7) + line_9 = line_7 - line_8 + line_10 = min(line_1, line_4) + line_11 = line_9 + line_12 = line_10 - line_11 + line_13 = FIFTEEN_PERCENT_RATE_AMOUNT_2025[status] + line_14 = min(line_1, line_13) + line_15 = line_5 + line_9 + line_16 = max(0, line_14 - line_15) + line_17 = min(line_12, line_16) + line_18 = 0.15 * line_17 + line_19 = line_9 + line_17 + line_20 = line_10 - line_19 + line_21 = 0.20 * line_20 + line_22 = tax_rate_schedule(line_5, status) + line_23 = line_18 + line_21 + line_22 + line_24 = tax_rate_schedule(line_1, status) + line_25 = min(line_23, line_24) + return line_5, line_23, line_25 + + +def reduce_by_capital_gain_excess(excess, qualified_dividends, net_gain): + """Footnote to the Foreign Earned Income Tax Worksheet, items 1 and 2. + + Returns the amounts for lines 2 and 3 of the Qualified Dividends and + Capital Gain Tax Worksheet. + """ + # 1. Reduce (but not below zero) line 3 by the capital gain excess. + line_3 = max(0, net_gain - excess) + # 2. Reduce (but not below zero) line 2 by any excess not used in (1). + line_2 = max(0, qualified_dividends - max(0, excess - net_gain)) + return line_2, line_3 + + +def foreign_earned_income_tax_worksheet( + taxable_income, excluded, qualified_dividends, net_gain, status +): + """2025 Form 1040 instructions, page 37. + + Returns line 6 figured with line 23 of the capital gains worksheet (what + the model computes), line 6 figured with its line 25 (what the form + says), and line 5 of the capital gains worksheet, which Form 6251 needs. + A filer with no exclusion gets the ordinary line 16 tax. + """ + line_1 = taxable_income + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + # "subtract Form 1040 or 1040-SR, line 15, from line 4 of your Qualified + # Dividends and Capital Gain Tax Worksheet ... If the result is more than + # zero, that amount is your capital gain excess." + excess = max(0, qualified_dividends + net_gain - line_1) if line_2c > 0 else 0 + dividends, gain = reduce_by_capital_gain_excess( + excess, qualified_dividends, net_gain + ) + ordinary_income, line_4, line_4_capped = ( + qualified_dividends_and_capital_gain_tax_worksheet( + line_3, dividends, gain, status + ) + ) + # "If Form 1040 or 1040-SR, line 15, is zero, don't complete this + # worksheet." + if line_1 <= 0: + return 0.0, 0.0, ordinary_income + line_5 = tax_rate_schedule(line_2c, status) + return max(0, line_4 - line_5), max(0, line_4_capped - line_5), ordinary_income + + +def amt_rates(amount, status): + """Form 6251 line 7, "All others", and Part III lines 18 and 39.""" + separate = status == "SEPARATE" + if amount <= (119_550 if separate else 239_100): + return 0.26 * amount + return 0.28 * amount - (2_391 if separate else 4_782) + + +def form_6251_part_iii(line_12, line_13, line_20, status): + """2025 Form 6251, lines 12 to 40, with no Schedule D Tax Worksheet. + + Without that worksheet line 14 is zero, line 15 equals line 13 and line + 27 equals line 20. + """ + line_15 = line_13 + line_16 = min(line_12, line_15) + line_17 = line_12 - line_16 + line_18 = amt_rates(line_17, status) + line_19 = ZERO_RATE_AMOUNT_2025[status] + line_21 = max(0, line_19 - line_20) + line_22 = min(line_12, line_13) + line_23 = min(line_21, line_22) + line_24 = line_22 - line_23 + line_25 = FIFTEEN_PERCENT_RATE_AMOUNT_2025[status] + line_26 = line_21 + line_27 = line_20 + line_28 = line_26 + line_27 + line_29 = max(0, line_25 - line_28) + line_30 = min(line_24, line_29) + line_31 = 0.15 * line_30 + line_32 = line_23 + line_30 + line_33 = line_22 - line_32 + line_34 = 0.20 * line_33 + line_38 = line_18 + line_31 + line_34 + line_39 = amt_rates(line_12, status) + return min(line_38, line_39) + + +def form_6251_line_7( + taxable_excess, excluded, qualified_dividends, net_gain, line_20, status +): + """Form 6251 line 7 through its Foreign Earned Income Tax Worksheet. + + 2025 Form 6251 instructions, page 10. `line_20` is line 5 of the capital + gains worksheet "as figured for the regular tax". A filer with no + exclusion gets the ordinary line 7 tax. + """ + # "If Form 6251, line 6, is zero, don't complete this worksheet." + if taxable_excess <= 0: + return 0.0 + line_1 = taxable_excess + line_2c = max(0, excluded) + line_3 = line_1 + line_2c + if qualified_dividends + net_gain > 0: + # "To see if you have an AMT capital gain excess, subtract Form 6251, + # line 6, from line 4 of your AMT Qualified Dividends and Capital + # Gain Tax Worksheet". + excess = max(0, qualified_dividends + net_gain - line_1) if line_2c > 0 else 0 + dividends, gain = reduce_by_capital_gain_excess( + excess, qualified_dividends, net_gain + ) + line_4 = form_6251_part_iii(line_3, dividends + gain, line_20, status) + else: + line_4 = amt_rates(line_3, status) + line_5 = amt_rates(line_2c, status) + return max(0, line_4 - line_5) + + +# --------------------------------------------------------------------------- +# The model. +# --------------------------------------------------------------------------- + +OUTPUTS = [ + "filing_status", + "tax_unit_itemizes", + "taxable_income", + "foreign_earned_income_exclusion", + "taxable_income_plus_section_911_exclusion", + "section_911_capital_gain_excess", + "income_tax_main_rates", + "capital_gains_tax", + "regular_tax_before_credits", + "amt_income_less_exemptions", + "amt_income_less_exemptions_plus_section_911_exclusion", + "amt_section_911_capital_gain_excess", + "alternative_minimum_tax", + "income_tax_before_credits", +] + + +def build_situation(households, year): + people, tax_units, marital_units = {}, {}, {} + groups = {"households": {}, "spm_units": {}, "families": {}} + for i, h in enumerate(households): + head = f"head_{i}" + people[head] = { + "age": {year: 45}, + "employment_income": {year: h["wages"]}, + "qualified_dividend_income": {year: h["qualified_dividends"]}, + "long_term_capital_gains": {year: h["long_term_gains"]}, + "short_term_capital_gains": {year: h["short_term_gains"]}, + "long_term_capital_gains_on_collectibles": { + year: h.get("collectibles_gains", 0) + }, + "real_estate_taxes": {year: h.get("real_estate_taxes", 0)}, + "deductible_mortgage_interest": {year: h.get("mortgage_interest", 0)}, + "charitable_cash_donations": {year: h.get("charitable_gifts", 0)}, + } + members = [head] + marital_units[f"marital_unit_{i}"] = {"members": [head]} + if h["status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {year: 45}} + members.append(spouse) + marital_units[f"marital_unit_{i}"]["members"].append(spouse) + if h["status"] == "HEAD_OF_HOUSEHOLD": + # Old enough to be a dependent without a child tax credit. + child = f"child_{i}" + people[child] = {"age": {year: 17}} + members.append(child) + marital_units[f"marital_unit_{i}_child"] = {"members": [child]} + tax_units[f"tax_unit_{i}"] = { + "members": members, + # Set for every tax unit: an input given to only some of them + # leaves the rest at the default (single), not at the status + # the model would compute. + "filing_status": {year: h["status"]}, + "foreign_earned_income_exclusion": {year: h["exclusion"]}, + "unrecaptured_section_1250_gain": { + year: h.get("unrecaptured_section_1250_gain", 0) + }, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + **groups, + } + + +def calculate(households, year): + simulation = Simulation(situation=build_situation(households, year)) + results = {} + for v in OUTPUTS: + values = simulation.calculate(v, year) + results[v] = ( + values.decode_to_str() if v == "filing_status" else np.asarray(values) + ) + results["regular_tax"] = ( + results["income_tax_main_rates"] + (results["capital_gains_tax"]) + ) + return results + + +def with_exclusion(households, exclusion): + """The same households with the exclusion replaced or transformed.""" + change = exclusion if callable(exclusion) else lambda _: exclusion + return [{**h, "exclusion": change(h["exclusion"])} for h in households] + + +def net_gain(household): + """Schedule D: the smaller of lines 15 and 16, or zero if either is a loss.""" + long_term = household["long_term_gains"] + return max(0, min(long_term, long_term + household["short_term_gains"])) + + +MODEL_TAXES = ["regular_tax", "income_tax_before_credits"] + + +def tolerance(*amounts): + """Five cents, plus the rounding of single-precision arithmetic. + + The model stores amounts as 32-bit floats, which carry about seven + significant digits, and the stacked tax is a difference of two taxes on + larger amounts. + """ + return 0.05 + 5e-7 * np.max(np.abs(amounts), axis=0) + + +def assert_matches_worksheets(households, law): + """The 2025 model against the transcribed 2025 worksheets.""" + cap_gaps = [] + for i, h in enumerate(households): + status = law["filing_status"][i] + assert status == h["status"], (i, status) + taxable_income = float(law["taxable_income"][i]) + gain = net_gain(h) + dividends = h["qualified_dividends"] + line_6, line_6_capped, ordinary_income = foreign_earned_income_tax_worksheet( + taxable_income, h["exclusion"], dividends, gain, status + ) + regular_tax = float(law["regular_tax"][i]) + stacked_income = taxable_income + h["exclusion"] + assert regular_tax == pytest.approx(line_6, abs=tolerance(stacked_income)), ( + i, + h, + ) + # The form's line 25 cap, which the model's section 1(h) formulas omit. + cap_gaps.append(regular_tax - line_6_capped) + line_7 = form_6251_line_7( + float(law["amt_income_less_exemptions"][i]), + h["exclusion"], + dividends, + gain, + ordinary_income, + status, + ) + assert -tolerance(stacked_income) <= cap_gaps[-1], (i, h) + assert cap_gaps[-1] <= 7.5 + tolerance(stacked_income), (i, h) + if status == "SEPARATE" and dividends + gain > 0: + # Form 6251 line 18 (see the module docstring). + continue + # Form 6251 lines 9 to 11 with no foreign tax credit. + amt = max(0, line_7 - line_6) + taxable_excess = float( + law["amt_income_less_exemptions_plus_section_911_exclusion"][i] + ) + assert float(law["alternative_minimum_tax"][i]) == pytest.approx( + amt, abs=tolerance(stacked_income, taxable_excess) + ), (i, h) + + +def assert_invariants(households, year): + law = calculate(households, year) + unstacked = calculate(with_exclusion(households, 0), year) + more_excluded = calculate(with_exclusion(households, lambda e: 1.5 * e + 500), year) + excluded = law["foreign_earned_income_exclusion"] + assert np.array_equal(excluded, [h["exclusion"] for h in households]) + excludes = excluded > 0 + taxable_income = law["taxable_income"] + # The exclusion changes the rates, not taxable income. + for other in [unstacked, more_excluded]: + assert np.array_equal(other["taxable_income"], taxable_income) + + # 1. A household without an exclusion gets the same results whatever the + # other households in the batch exclude. (test_nothing_excluded_changes_ + # nothing checks the amounts section 911(f) replaces.) + for v in OUTPUTS[1:]: + assert np.array_equal(law[v][~excludes], unstacked[v][~excludes]), v + assert np.array_equal( + unstacked["taxable_income_plus_section_911_exclusion"], taxable_income + ) + assert np.array_equal( + unstacked["amt_income_less_exemptions_plus_section_911_exclusion"], + unstacked["amt_income_less_exemptions"], + ) + for v in ["section_911_capital_gain_excess", "amt_section_911_capital_gain_excess"]: + assert not unstacked[v].any(), v + + # 2. Worksheet line 3 is line 1 plus line 2c. + slack = tolerance( + taxable_income + more_excluded["foreign_earned_income_exclusion"], + more_excluded["amt_income_less_exemptions_plus_section_911_exclusion"], + ) + assert ( + np.abs( + law["taxable_income_plus_section_911_exclusion"] + - (taxable_income + excluded) + ) + <= slack + ).all() + + for v in MODEL_TAXES: + # 3. Stacking never lowers the tax ... + assert (law[v] >= unstacked[v] - slack).all(), v + # 4. ... and the tax never falls as the excluded amount rises. + assert (more_excluded[v] >= law[v] - slack).all(), v + assert (law[v] >= 0).all(), v + + # 5. Section 911(f)(1)(A) applies "if such taxpayer has taxable income": + # with none, there is no regular tax, whatever is excluded. + no_taxable_income = taxable_income == 0 + for results in [law, unstacked, more_excluded]: + assert (results["regular_tax"][no_taxable_income] == 0).all() + + # 6. Each dollar of taxable income bears at most the top rate, so the + # regular tax is at most that rate times taxable income (37% in every + # year tested). + assert (law["regular_tax"] <= 0.37 * taxable_income + slack).all() + + # 7. The capital gain excess is the part of the gains above taxable + # income, and only a filer with an exclusion has one. + assert (law["section_911_capital_gain_excess"] >= 0).all() + assert not law["section_911_capital_gain_excess"][~excludes].any() + assert not law["amt_section_911_capital_gain_excess"][~excludes].any() + return law, unstacked + + +STATUSES = ["SINGLE", "JOINT", "HEAD_OF_HOUSEHOLD", "SEPARATE"] + +GRID = [ + { + "status": status, + "wages": wages, + "qualified_dividends": dividends, + "long_term_gains": long_term, + "short_term_gains": short_term, + "exclusion": exclusion, + } + for status in STATUSES + for wages in (0, 20_000, 59_975, 140_000, 700_000) + for dividends, long_term, short_term in ( + (0, 0, 0), + (3_000, 0, 0), + (0, 50_000, 0), + (10_000, 40_000, -15_000), + (0, 30_000, 20_000), + (25_000, 900_000, 0), + (0, -8_000, 5_000), + ) + for exclusion in (0, 1, 40_025, 130_000, 260_000) +] + + +def test_grid_matches_the_2025_worksheets(): + law = calculate(GRID, 2025) + assert_matches_worksheets(GRID, law) + # The grid reaches every part of the computation. + excludes = law["foreign_earned_income_exclusion"] > 0 + assert (law["section_911_capital_gain_excess"] > 0).any() + assert (law["amt_section_911_capital_gain_excess"] > 0).any() + assert (excludes & (law["capital_gains_tax"] > 0)).any() + assert (excludes & (law["alternative_minimum_tax"] > 0)).any() + assert (excludes & (law["taxable_income"] == 0)).any() + + +@pytest.mark.parametrize("year", [2018, 2022, 2025, 2026]) +def test_grid_invariants(year): + law, unstacked = assert_invariants(GRID, year) + # The grid reaches filers whose tax the stacking raises. + assert (law["regular_tax"] > unstacked["regular_tax"] + 1).any() + + +def test_review_example_from_the_tax_table(): + # 2025, head of household, $60,000 of included wages and $40,000 + # excluded. Worksheet: line 1 is $36,375 ($60,000 less the $23,625 + # standard deduction), line 2c $40,000, line 3 $76,375. The Tax Table + # gives $9,978 on line 3 and $4,463 on line 2c, so line 6 is $5,515. + # The Tax Table taxes the middle of each $50 row; the model taxes the + # exact amounts ($9,977.50 less $4,460.00). + household = { + "status": "HEAD_OF_HOUSEHOLD", + "wages": 60_000, + "qualified_dividends": 0, + "long_term_gains": 0, + "short_term_gains": 0, + "exclusion": 40_000, + } + law = calculate([household], 2025) + assert law["taxable_income"][0] == 36_375 + assert law["regular_tax"][0] == pytest.approx(5_517.50, abs=0.01) + assert law["regular_tax"][0] == pytest.approx(9_978 - 4_463, abs=3) + unstacked = calculate(with_exclusion([household], 0), 2025) + # $4,025 is the Tax Table amount for $36,375. + assert unstacked["regular_tax"][0] == pytest.approx(4_025, abs=0.01) + + +# The Tax Table's column order. +STATUSES_TABLE_ORDER = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD"] + +# Rows of the 2025 Tax Table (2025 Instructions for Form 1040): at least, +# but less than, then the tax for single, married filing jointly, married +# filing separately and head of household filers. The table taxes the middle +# of each row and rounds to the dollar. +TAX_TABLE_2025_ROWS = [ + (11_900, 11_950, 1_193, 1_193, 1_193, 1_193), + (17_000, 17_050, 1_805, 1_703, 1_805, 1_703), + (23_850, 23_900, 2_627, 2_388, 2_627, 2_525), + (30_000, 30_050, 3_365, 3_126, 3_365, 3_263), + (36_350, 36_400, 4_127, 3_888, 4_127, 4_025), + (40_000, 40_050, 4_565, 4_326, 4_565, 4_463), + (48_450, 48_500, 5_579, 5_340, 5_579, 5_477), + (60_050, 60_100, 8_131, 6_732, 8_131, 6_869), + (64_800, 64_850, 9_176, 7_302, 9_176, 7_439), + (76_350, 76_400, 11_717, 8_688, 11_717, 9_978), + (96_900, 96_950, 16_238, 11_154, 16_238, 14_499), + (99_950, 100_000, 16_909, 11_823, 16_909, 15_170), +] +# 2025 Tax Computation Worksheet: taxable income, multiplication amount and +# subtraction amount, one row from each section. +TAX_COMPUTATION_WORKSHEET_2025_ROWS = { + "SINGLE": [(150_000, 0.24, 7_153), (700_000, 0.37, 42_979.75)], + "JOINT": [(300_000, 0.24, 14_306), (800_000, 0.37, 75_937.50)], + "SEPARATE": [(220_000, 0.32, 22_937), (400_000, 0.37, 37_968.75)], + "HEAD_OF_HOUSEHOLD": [(120_000, 0.24, 8_892), (300_000, 0.35, 32_191)], +} + + +def test_transcribed_schedule_matches_the_2025_tax_table(): + for low, high, *taxes in TAX_TABLE_2025_ROWS: + for status, tax in zip(STATUSES_TABLE_ORDER, taxes): + # Round half up to the dollar, as the table does. + assert int(tax_rate_schedule((low + high) / 2, status) + 0.5) == tax, ( + low, + status, + ) + for status, rows in TAX_COMPUTATION_WORKSHEET_2025_ROWS.items(): + for amount, rate, subtraction in rows: + assert tax_rate_schedule(amount, status) == pytest.approx( + amount * rate - subtraction, abs=1e-6 + ), (amount, status) + + +# Households with every kind of gain the computation touches, itemizers +# among them. +REPLACED_AMOUNTS_GRID = [ + { + "status": status, + "wages": wages, + "qualified_dividends": dividends, + "long_term_gains": long_term, + "short_term_gains": short_term, + "collectibles_gains": collectibles, + "unrecaptured_section_1250_gain": section_1250, + "real_estate_taxes": real_estate_taxes, + "mortgage_interest": 0, + "charitable_gifts": charitable_gifts, + "exclusion": exclusion, + } + for status in STATUSES + for wages in (0, 60_000, 400_000) + for dividends, long_term, short_term, collectibles, section_1250 in ( + (0, 0, 0, 0, 0), + (5_000, 80_000, -10_000, 20_000, 15_000), + (0, 30_000, 0, 30_000, 0), + (20_000, 600_000, 40_000, 0, 100_000), + ) + for real_estate_taxes, charitable_gifts in ((0, 0), (12_000, 40_000)) + for exclusion in (0, 50_000) +] + [ + # Amounts with cents, which single precision cannot store exactly, so a + # rounding step that whole-dollar amounts would hide shows up here. + { + "status": status, + "wages": 61_234.56, + "qualified_dividends": 12_345.67, + "long_term_gains": 100_000.01, + "short_term_gains": -1_234.89, + "collectibles_gains": 0, + "unrecaptured_section_1250_gain": 15_000.37, + "real_estate_taxes": 0, + "mortgage_interest": 0, + "charitable_gifts": 0, + "exclusion": exclusion, + } + for status in STATUSES + for exclusion in (0, 50_000.25) +] + + +def test_nothing_excluded_changes_nothing(): + """With no capital gain excess, each amount that section 911(f) replaces + equals the amount it replaces, bit for bit, and with nothing excluded so + do the stacked bases.""" + from policyengine_core.periods import period as make_period + + from policyengine_us.model_api import add, max_ + from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, + ) + from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.section_911_net_capital_gain_other_than_dividends import ( + section_911_net_capital_gain_other_than_dividends, + ) + + for year in [2018, 2025, 2026]: + simulation = Simulation(situation=build_situation(REPLACED_AMOUNTS_GRID, year)) + + def get(variable): + return np.asarray(simulation.calculate(variable, year)) + + excluded = get("foreign_earned_income_exclusion") + nothing_excluded = excluded == 0 + no_excess = get("section_911_capital_gain_excess") == 0 + assert not get("section_911_capital_gain_excess")[nothing_excluded].any() + assert not get("amt_section_911_capital_gain_excess")[nothing_excluded].any() + # The grid has filers with an excess and filers without. + assert (~no_excess).any() and (no_excess & ~nothing_excluded).any() + assert get("tax_unit_itemizes").any() and not get("tax_unit_itemizes").all() + + dividends = np.asarray( + simulation.calculate("qualified_dividend_income", year, map_to="tax_unit") + ) + pairs = { + "section_911_net_capital_gain": get("net_capital_gain"), + "section_911_qualified_dividend_income": dividends, + "section_911_28_percent_rate_gain": get( + "capital_gains_28_percent_rate_gain" + ), + "section_911_unrecaptured_section_1250_gain": get( + "unrecaptured_section_1250_gain" + ), + "section_911_adjusted_net_capital_gain": get("adjusted_net_capital_gain"), + } + for variable, original in pairs.items(): + assert np.array_equal(get(variable)[no_excess], original[no_excess]), ( + variable, + year, + ) + tax_unit = simulation.populations["tax_unit"] + period = make_period(year) + # The reduced non-dividend gain against the expression main used in + # capital_gains_tax, computed the same way (not stored in between). + main_gain = max_( + 0, + tax_unit("net_capital_gain", period) + - add(tax_unit, period, ["qualified_dividend_income"]), + ) + assert np.array_equal( + np.asarray( + section_911_net_capital_gain_other_than_dividends(tax_unit, period) + )[no_excess], + np.asarray(main_gain)[no_excess], + ), year + worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, get("taxable_income") + ) + no_excess = no_excess & (np.asarray(worksheet.capital_gain_excess) == 0) + for line, original in [ + ("line_9", "dwks09"), + ("line_10", "dwks10"), + ("line_13", "dwks13"), + ("unrecaptured_section_1250_gain", "unrecaptured_section_1250_gain"), + ]: + assert np.array_equal( + np.asarray(getattr(worksheet, line))[no_excess], + get(original)[no_excess], + ), (line, year) + for stacked, original in [ + ("taxable_income_plus_section_911_exclusion", "taxable_income"), + ( + "amt_income_less_exemptions_plus_section_911_exclusion", + "amt_income_less_exemptions", + ), + ]: + assert np.array_equal( + get(stacked)[nothing_excluded], get(original)[nothing_excluded] + ), (stacked, year) + + +household_strategy = st.fixed_dictionaries( + { + "status": st.sampled_from(STATUSES), + "wages": st.integers(0, 900_000), + "qualified_dividends": st.one_of(st.just(0), st.integers(1, 300_000)), + "long_term_gains": st.one_of(st.just(0), st.integers(-50_000, 1_500_000)), + "short_term_gains": st.one_of(st.just(0), st.integers(-100_000, 100_000)), + "exclusion": st.one_of(st.just(0), st.integers(1, 300_000)), + "real_estate_taxes": st.one_of(st.just(0), st.integers(1, 60_000)), + "mortgage_interest": st.one_of(st.just(0), st.integers(1, 80_000)), + "charitable_gifts": st.one_of(st.just(0), st.integers(1, 150_000)), + } +) + +SETTINGS = dict( + max_examples=10, + deadline=None, + suppress_health_check=[ + hypothesis.HealthCheck.too_slow, + hypothesis.HealthCheck.data_too_large, + ], +) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given( + st.lists(household_strategy, min_size=1, max_size=25), + st.sampled_from([2018, 2022, 2025, 2026]), +) +def test_random_households_keep_the_invariants(households, year): + assert_invariants(households, year) + + +@hypothesis.settings(**SETTINGS) +@hypothesis.given(st.lists(household_strategy, min_size=1, max_size=25)) +def test_random_households_match_the_2025_worksheets(households): + assert_matches_worksheets(households, calculate(households, 2025)) 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 43023bc8f62..44dbb48992b 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 @@ -4,8 +4,11 @@ class foreign_earned_income_exclusion(Variable): value_type = float entity = TaxUnit - label = "Foreign earned income ALD" + 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." + 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)." definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/911" + reference = [ + "https://www.law.cornell.edu/uscode/text/26/911", + "https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py index e97bee52234..5de3a171f33 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/alternative_minimum_tax.py @@ -11,7 +11,7 @@ class alternative_minimum_tax(Variable): def formula(tax_unit, period, parameters): # Line 7 consists of 3 parts: - # 1. Tax on Foreign income (not modelled) + # 1. Tax on Foreign income (Foreign Earned Income Tax Worksheet) # 2. Tax on capital gains (Part III) # 3. Regular AMT tax # If Form 6251, Part III is required, the regular AMT tax is calculated @@ -27,6 +27,27 @@ def formula(tax_unit, period, parameters): smaller_tax = min_(amt_base_tax, amt_tax_including_cg) total_amt_tax = where(form_6251_part_iii_required, smaller_tax, amt_base_tax) + # 26 U.S.C. 911(f)(1)(B): a taxpayer excluding foreign earned income + # figures the taxes above on the taxable excess plus the excluded + # amount, then subtracts the tax at the AMT rates on the excluded + # amount alone (Form 6251 Foreign Earned Income Tax Worksheet, lines + # 5 and 6). It applies only if there is a taxable excess. + p = parameters(period).gov.irs.income.amt + filing_status = tax_unit("filing_status", period) + taxable_excess = tax_unit("amt_income_less_exemptions", period) + excluded = where( + taxable_excess > 0, + max_(0, tax_unit("foreign_earned_income_exclusion", period)), + 0, + ) + tax_rate_threshold = p.brackets.thresholds[-1] * p.multiplier[filing_status] + tax_on_excluded = p.brackets.rates[0] * min_( + excluded, tax_rate_threshold + ) + p.brackets.rates[1] * max_(0, excluded - tax_rate_threshold) + total_amt_tax = where( + excluded > 0, max_(0, total_amt_tax - tax_on_excluded), total_amt_tax + ) + # Form 6251, Part II bottom # Line 8 foreign_tax_credit = tax_unit("foreign_tax_credit_potential", period) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py index b9b6013feac..e58443255fe 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/amt_tax_including_cg.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) class amt_tax_including_cg(Variable): @@ -11,17 +14,26 @@ class amt_tax_including_cg(Variable): reference = "https://www.irs.gov/pub/irs-pdf/f6251.pdf" def formula(tax_unit, period, parameters): - # Form 6251 Part III, Line 12: AMTI minus exemption - reduced_income = tax_unit("amt_income_less_exemptions", period) + # Form 6251 Part III, Line 12: AMTI minus exemption, or for a Form + # 2555 filer line 3 of the Form 6251 Foreign Earned Income Tax + # Worksheet (26 U.S.C. 911(f)(1)(B)(i)). + reduced_income = tax_unit( + "amt_income_less_exemptions_plus_section_911_exclusion", period + ) + # A Form 2555 filer figures lines 13 to 15 after reducing the gains + # by the AMT capital gain excess (26 U.S.C. 911(f)(2)(B)(i)). + worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("amt_income_less_exemptions", period) + ) # Line 13: amount from QDCG Worksheet line 4 or Schedule D Tax # Worksheet line 13 (qualified dividends + LTCG net of unrecaptured # section 1250 and 28%-rate gains). - cg_distributions = tax_unit("dwks13", period) + cg_distributions = worksheet.line_13 # Line 14: Schedule D line 19 (unrecaptured section 1250 gain). - section_1250_gain_worksheet = tax_unit("unrecaptured_section_1250_gain", period) + section_1250_gain_worksheet = worksheet.unrecaptured_section_1250_gain # Line 15: smaller of (Line 13 + Line 14) or Schedule D Tax Worksheet # line 10. - total_transactions_reported = tax_unit("dwks10", period) + total_transactions_reported = worksheet.line_10 capped_capital_gains = min_( cg_distributions + section_1250_gain_worksheet, total_transactions_reported, @@ -38,7 +50,9 @@ def formula(tax_unit, period, parameters): cg_bracket = p.capital_gains.thresholds["1"][filing_status] # Line 20: amount from QDCG Worksheet line 5 or Schedule D Tax # Worksheet line 14 (as figured for the regular tax). This is the - # ordinary-income portion of taxable income. + # ordinary-income portion of taxable income. For a Form 2555 filer + # it reflects the regular tax capital gain excess, not the AMT one + # (26 U.S.C. 911(f)(2)(B)(ii)). regular_ordinary_income = tax_unit("dwks14", period) # Line 21: Line 19 minus Line 20. Room left in the 0% bracket after # accounting for ordinary income already filling it. @@ -87,11 +101,8 @@ def formula(tax_unit, period, parameters): # Line 37: Line 36 times the 25% rate, only when unrecaptured section # 1250 gains exist (per the form's "skip lines 35-37 if line 14 is # zero" instruction). - unrecaptured_section_1250_gain = tax_unit( - "unrecaptured_section_1250_gain", period - ) excess_tax = where( - unrecaptured_section_1250_gain == 0, + section_1250_gain_worksheet == 0, 0, final_excess * p.income.amt.capital_gains.capital_gain_excess_tax_rate, ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.py index 9c13d570c7f..856a026683a 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_higher_base_tax.py @@ -13,7 +13,11 @@ class amt_higher_base_tax(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.income.amt filing_status = tax_unit("filing_status", period) - reduced_income = tax_unit("amt_income_less_exemptions", period) + # Form 6251 line 6, or for a Form 2555 filer line 3 of the Form 6251 + # Foreign Earned Income Tax Worksheet (26 U.S.C. 911(f)(1)(B)(i)). + reduced_income = tax_unit( + "amt_income_less_exemptions_plus_section_911_exclusion", period + ) bracket_fraction = p.multiplier[filing_status] tax_rate_threshold = p.brackets.thresholds[-1] * bracket_fraction higher_rate = p.brackets.rates[1] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.py index fea07ad0982..39c73f7a501 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/base_tax/amt_lower_base_tax.py @@ -13,7 +13,11 @@ class amt_lower_base_tax(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.income.amt filing_status = tax_unit("filing_status", period) - reduced_income = tax_unit("amt_income_less_exemptions", period) + # Form 6251 line 6, or for a Form 2555 filer line 3 of the Form 6251 + # Foreign Earned Income Tax Worksheet (26 U.S.C. 911(f)(1)(B)(i)). + reduced_income = tax_unit( + "amt_income_less_exemptions_plus_section_911_exclusion", period + ) bracket_fraction = p.multiplier[filing_status] tax_rate_threshold = p.brackets.thresholds[-1] * bracket_fraction lower_rate = p.brackets.rates[0] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py index 1cfb092dd59..bfce22dbb4f 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/alternative_minimum_tax/regular_tax_before_credits.py @@ -1,4 +1,10 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.before_credits.tax_at_main_rates import ( + tax_at_main_rates, +) +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) class regular_tax_before_credits(Variable): @@ -12,14 +18,20 @@ class regular_tax_before_credits(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) - dwks1 = tax_unit("taxable_income", period) + # A Form 2555 filer enters line 3 of the Foreign Earned Income Tax + # Worksheet on line 1 and refigures the capital gain lines for any + # capital gain excess (26 U.S.C. 911(f)). + dwks1 = tax_unit("taxable_income_plus_section_911_exclusion", period) + worksheet = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("taxable_income", period) + ) dwks16 = min_(p.capital_gains.thresholds["1"][filing_status], dwks1) dwks17 = min_(tax_unit("dwks14", period), dwks16) dwks20 = dwks16 - dwks17 lowest_rate_tax = p.capital_gains.rates["1"] * dwks20 # Break in worksheet lines - dwks13 = tax_unit("dwks13", period) + dwks13 = worksheet.line_13 dwks21 = min_(dwks1, dwks13) dwks22 = dwks20 dwks23 = max_(0, dwks21 - dwks22) @@ -33,11 +45,8 @@ def formula(tax_unit, period, parameters): dwks31 = dwks21 - dwks30 dwks32 = p.capital_gains.rates["3"] * dwks31 # Break in worksheet lines - dwks33 = min_( - tax_unit("dwks09", period), - add(tax_unit, period, ["unrecaptured_section_1250_gain"]), - ) - dwks10 = tax_unit("dwks10", period) + dwks33 = min_(worksheet.line_9, worksheet.unrecaptured_section_1250_gain) + dwks10 = worksheet.line_10 dwks34 = dwks10 + dwks19 dwks36 = max_(0, dwks34 - dwks1) dwks37 = max_(0, dwks33 - dwks36) @@ -49,26 +58,22 @@ def formula(tax_unit, period, parameters): dwks41 = p.income.amt.brackets.rates[-1] * dwks40 # Compute regular tax using bracket rates and thresholds + # The shared schedule clamps inverted brackets as in + # income_tax_main_rates (#9084), or the AMT comparator diverges from + # the main-rates tax and manufactures phantom AMT for qdiv/LTCG + # filers. reg_taxinc = max_(0, dwks19) - bracket_tops = p.income.bracket.thresholds - bracket_rates = p.income.bracket.rates - reg_tax = 0 - bracket_bottom = 0 - for i in range(1, len(list(bracket_rates.__iter__())) + 1): - b = str(i) - # Clamp as in income_tax_main_rates (#9084): an inverted - # bracket must contribute zero width here too, or the AMT - # comparator diverges from the corrected main-rates tax and - # manufactures phantom AMT for qdiv/LTCG filers. - bracket_top = max_(bracket_bottom, bracket_tops[b][filing_status]) - reg_tax += bracket_rates[b] * amount_between( - reg_taxinc, bracket_bottom, bracket_top - ) - bracket_bottom = bracket_top + reg_tax = tax_at_main_rates(reg_taxinc, filing_status, p.income.bracket) # Return to worksheet lines dwks42 = reg_tax dwks43 = dwks29 + dwks32 + dwks38 + dwks41 + dwks42 + lowest_rate_tax + # Foreign Earned Income Tax Worksheet, lines 4 to 6: the worksheet + # tax on line 3 less the tax on the excluded amount alone. + # income_tax_main_rates is already net of it. + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, p.income.bracket) + dwks43 = where(excluded > 0, max_(0, dwks43 - tax_on_excluded), dwks43) dwks44 = tax_unit("income_tax_main_rates", period) dwks45 = min_(dwks43, dwks44) return where(tax_unit("has_qdiv_or_ltcg", period), dwks45, dwks44) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.py b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.py index b7529fb1658..f2e6adc32e0 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/income_tax_main_rates.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.before_credits.tax_at_main_rates import ( + tax_at_main_rates, +) class income_tax_main_rates(Variable): @@ -6,31 +9,30 @@ class income_tax_main_rates(Variable): entity = TaxUnit definition_period = YEAR label = "Income tax main rates" - reference = "https://www.law.cornell.edu/uscode/text/26/1" + reference = [ + "https://www.law.cornell.edu/uscode/text/26/1", + "https://www.law.cornell.edu/uscode/text/26/911#f_1_A", + "https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ] unit = USD def formula(tax_unit, period, parameters): - # compute taxable income that is taxed at the main rates - full_taxable_income = tax_unit("taxable_income", period) + # compute taxable income that is taxed at the main rates; a taxpayer + # excluding foreign earned income adds the excluded amount back + # (Foreign Earned Income Tax Worksheet, line 3) + full_taxable_income = tax_unit( + "taxable_income_plus_section_911_exclusion", period + ) cg_exclusion = tax_unit("capital_gains_excluded_from_taxable_income", period) taxinc = max_(0, full_taxable_income - cg_exclusion) # compute tax using bracket rates and thresholds - p = parameters(period).gov.irs.income - bracket_tops = p.bracket.thresholds - bracket_rates = p.bracket.rates + bracket = parameters(period).gov.irs.income.bracket filing_status = tax_unit("filing_status", period) - tax = 0 - bracket_bottom = 0 - for i in range(1, len(list(bracket_rates.__iter__())) + 1): - b = str(i) - # Clamp to the running lower bound: with a non-monotone - # threshold configuration, amount_between's clip returns the - # upper bound for every input, so an inverted bracket would - # add rate * (top - bottom) < 0 to every filer of the status - # at any income (#9084). - bracket_top = max_(bracket_bottom, bracket_tops[b][filing_status]) - tax += bracket_rates[b] * amount_between( - taxinc, bracket_bottom, bracket_top - ) - bracket_bottom = bracket_top - return tax + tax = tax_at_main_rates(taxinc, filing_status, bracket) + # 26 U.S.C. 911(f)(1)(A): less the tax on the excluded amount alone + # (worksheet lines 5 and 6). The capital gain excess rule keeps the + # excluded amount in the main-rates base, so the difference is never + # negative here. + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + tax_on_excluded = tax_at_main_rates(excluded, filing_status, bracket) + return where(excluded > 0, max_(0, tax - tax_on_excluded), tax) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_at_main_rates.py b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_at_main_rates.py new file mode 100644 index 00000000000..ccdad76e0d8 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/before_credits/tax_at_main_rates.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +def tax_at_main_rates(taxable_amount, filing_status, bracket): + """Tax on an amount at the ordinary rate schedule of 26 U.S.C. 1. + + `bracket` is a bracket parameter node with `rates` and `thresholds`, + such as `parameters(period).gov.irs.income.bracket` or a contrib + reform's replacement schedule. + """ + tax = 0 + bracket_bottom = 0 + for i in range(1, len(list(bracket.rates.__iter__())) + 1): + b = str(i) + # Clamp to the running lower bound: with a non-monotone threshold + # configuration, amount_between's clip returns the upper bound for + # every input, so an inverted bracket would add + # rate * (top - bottom) < 0 to every filer of the status at any + # income (#9084). + bracket_top = max_(bracket_bottom, bracket.thresholds[b][filing_status]) + tax += bracket.rates[b] * amount_between( + taxable_amount, bracket_bottom, bracket_top + ) + bracket_bottom = bracket_top + return tax diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.py index cf82da6a76c..e367ee9c63c 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_excluded_from_taxable_income.py @@ -6,18 +6,30 @@ class capital_gains_excluded_from_taxable_income(Variable): entity = TaxUnit label = "Capital gains excluded from taxable income" unit = USD - documentation = "This is subtracted from taxable income before applying the ordinary tax rates. Capital gains tax is calculated separately." + documentation = "This is subtracted from taxable income (plus any section 911 exclusion) before applying the ordinary tax rates. Capital gains tax is calculated separately." definition_period = YEAR - reference = dict( - title="26 U.S. Code § 1(h)(1)(A)", - href="https://www.law.cornell.edu/uscode/text/26/1#h_1_A", - ) + reference = [ + dict( + title="26 U.S. Code § 1(h)(1)(A)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_1_A", + ), + dict( + title="26 U.S. Code § 911(f)", + href="https://www.law.cornell.edu/uscode/text/26/911#f", + ), + ] def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.capital_gains - net_capital_gain = tax_unit("net_capital_gain", period) - adjusted_net_capital_gain = tax_unit("adjusted_net_capital_gain", period) - taxable_income = tax_unit("taxable_income", period) + # A taxpayer excluding foreign earned income applies section 1(h) to + # taxable income plus the excluded amount, with gains reduced by any + # capital gain excess (26 U.S.C. 911(f)). Everyone else's amounts are + # unchanged. + net_capital_gain = tax_unit("section_911_net_capital_gain", period) + adjusted_net_capital_gain = tax_unit( + "section_911_adjusted_net_capital_gain", period + ) + taxable_income = tax_unit("taxable_income_plus_section_911_exclusion", period) filing_status = tax_unit("filing_status", period) income_taxed_below_first_rate = clip( taxable_income, 0, p.thresholds["1"][filing_status] diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py index dbe304180ad..478968aa924 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/capital_gains_tax.py @@ -1,5 +1,8 @@ from numpy import clip from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.section_911_net_capital_gain_other_than_dividends import ( + section_911_net_capital_gain_other_than_dividends, +) class capital_gains_tax(Variable): @@ -8,12 +11,26 @@ class capital_gains_tax(Variable): label = "Maximum income tax after capital gains tax" unit = USD definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 1(h)(1)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_1", + ), + dict( + title="26 U.S. Code § 911(f)", + href="https://www.law.cornell.edu/uscode/text/26/911#f", + ), + ] def formula(tax_unit, period, parameters): - net_cg = tax_unit("net_capital_gain", period) - taxable_income = tax_unit("taxable_income", period) + # A taxpayer excluding foreign earned income applies section 1(h) to + # taxable income plus the excluded amount, with gains reduced by any + # capital gain excess (26 U.S.C. 911(f)). Everyone else's amounts are + # unchanged. + net_cg = tax_unit("section_911_net_capital_gain", period) + taxable_income = tax_unit("taxable_income_plus_section_911_exclusion", period) adjusted_net_cg = min_( - tax_unit("adjusted_net_capital_gain", period), + tax_unit("section_911_adjusted_net_capital_gain", period), taxable_income, ) # ANCG is referred to in all cases as ANCG or taxable income if less. @@ -54,11 +71,18 @@ def formula(tax_unit, period, parameters): + cg_in_third_bracket * cg.rates["3"] ) - unrecaptured_s_1250_gain = tax_unit("unrecaptured_section_1250_gain", period) - qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + unrecaptured_s_1250_gain = tax_unit( + "section_911_unrecaptured_section_1250_gain", period + ) + # Net capital gain determined without regard to section 1(h)(11), + # reduced first by any capital gain excess (26 U.S.C. + # 911(f)(2)(A)(i)). + net_cg_other_than_dividends = section_911_net_capital_gain_other_than_dividends( + tax_unit, period + ) max_taxable_unrecaptured_gain = min_( unrecaptured_s_1250_gain, - max_(0, net_cg - qualified_dividends), + net_cg_other_than_dividends, ) unrecaptured_gain_deduction = max_( non_cg_taxable_income + net_cg - taxable_income, @@ -72,7 +96,7 @@ def formula(tax_unit, period, parameters): unrecaptured_gain_tax = cg.unrecaptured_s_1250_rate * taxable_unrecaptured_gain remaining_cg_tax = ( - tax_unit("capital_gains_28_percent_rate_gain", period) * cg.other_cg_rate + tax_unit("section_911_28_percent_rate_gain", period) * cg.other_cg_rate ) return main_cg_tax + unrecaptured_gain_tax + remaining_cg_tax diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks14.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks14.py index 335d8e77527..0dfab49cd79 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks14.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks14.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) class dwks14(Variable): @@ -9,6 +12,11 @@ class dwks14(Variable): unit = USD def formula(tax_unit, period, parameters): - dwks01 = tax_unit("taxable_income", period) - dwks13 = tax_unit("dwks13", period) + # Line 1 is taxable income, or for a Form 2555 filer line 3 of the + # Foreign Earned Income Tax Worksheet, and line 13 reflects any + # capital gain excess. + dwks01 = tax_unit("taxable_income_plus_section_911_exclusion", period) + dwks13 = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("taxable_income", period) + ).line_13 return max_(0, dwks01 - dwks13) * tax_unit("has_qdiv_or_ltcg", period) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks19.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks19.py index 638d9870c4d..6a63ea5dbdd 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks19.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/dwks19.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) class dwks19(Variable): @@ -12,9 +15,14 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.capital_gains dwks14 = tax_unit("dwks14", period) filing_status = tax_unit("filing_status", period) - dwks01 = tax_unit("taxable_income", period) + # Line 1 is taxable income, or for a Form 2555 filer line 3 of the + # Foreign Earned Income Tax Worksheet, and line 10 reflects any + # capital gain excess. + dwks01 = tax_unit("taxable_income_plus_section_911_exclusion", period) dwks16 = min_(p.thresholds["1"][filing_status], dwks01) dwks17 = min_(dwks14, dwks16) - dwks10 = tax_unit("dwks10", period) + dwks10 = schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("taxable_income", period) + ).line_10 dwks18 = max_(0, dwks01 - dwks10) return max_(dwks17, dwks18) * tax_unit("has_qdiv_or_ltcg", period) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.py new file mode 100644 index 00000000000..664810f7a87 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_income_less_exemptions_plus_section_911_exclusion.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class amt_income_less_exemptions_plus_section_911_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "AMT taxable excess plus the section 911 exclusion" + unit = USD + documentation = ( + "The AMT taxable excess (alternative minimum taxable income less the " + "exemption) increased by the foreign earned income and housing amounts " + "excluded under 26 U.S.C. 911(a), when there is a taxable excess. The " + "tentative minimum tax of a taxpayer excluding foreign earned income " + "is the tax on this amount less the tax on the excluded amount alone. " + "Line 3 of the Form 6251 Foreign Earned Income Tax Worksheet; equals " + "the taxable excess for everyone else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(1)(B)(i)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_1_B_i", + ), + dict( + title="2025 Form 6251 instructions, Foreign Earned Income Tax Worksheet—Line 7, line 3", + href="https://www.irs.gov/pub/irs-pdf/i6251.pdf#page=10", + ), + ] + + def formula(tax_unit, period, parameters): + taxable_excess = tax_unit("amt_income_less_exemptions", period) + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + # Section 911(f)(1)(B) applies only if there is a taxable excess; the + # worksheet is skipped when Form 6251 line 6 is zero. + return taxable_excess + where(taxable_excess > 0, excluded, 0) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.py new file mode 100644 index 00000000000..5a981f6be88 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/amt_section_911_capital_gain_excess.py @@ -0,0 +1,39 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.schedule_d_tax_worksheet_after_capital_gain_excess import ( + schedule_d_tax_worksheet_after_capital_gain_excess, +) + + +class amt_section_911_capital_gain_excess(Variable): + value_type = float + entity = TaxUnit + label = "AMT section 911 capital gain excess" + unit = USD + documentation = ( + "For a taxpayer excluding foreign earned income under 26 U.S.C. " + "911(a), the excess of line 10 of the Schedule D Tax Worksheet over " + "the AMT taxable excess (Form 6251 line 6). The capital gains used in " + "Form 6251 Part III are reduced by this excess. Reported for " + "reference: amt_tax_including_cg figures it itself, so an input here " + "does not change the AMT." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(B)(i)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_B_i", + ), + dict( + title="2025 Form 6251 instructions, Part III, Form 2555", + href="https://www.irs.gov/pub/irs-pdf/i6251.pdf#page=13", + ), + ] + + def formula(tax_unit, period, parameters): + # Form 6251 instructions: "subtract Form 6251, line 6, from line 4 of + # your AMT Qualified Dividends and Capital Gain Tax Worksheet or line + # 10 of your AMT Schedule D Tax Worksheet". amt_tax_including_cg + # applies it through schedule_d_tax_worksheet_after_capital_gain_excess. + return schedule_d_tax_worksheet_after_capital_gain_excess( + tax_unit, period, tax_unit("amt_income_less_exemptions", period) + ).capital_gain_excess diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py new file mode 100644 index 00000000000..1aab78c733f --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/schedule_d_tax_worksheet_after_capital_gain_excess.py @@ -0,0 +1,82 @@ +from collections import namedtuple + +from policyengine_us.model_api import * + +ScheduleDTaxWorksheet = namedtuple( + "ScheduleDTaxWorksheet", + [ + "capital_gain_excess", + "line_9", + "line_10", + "line_13", + "unrecaptured_section_1250_gain", + ], +) + + +def schedule_d_tax_worksheet_after_capital_gain_excess(tax_unit, period, taxable_base): + """Schedule D Tax Worksheet amounts refigured for a capital gain excess. + + `taxable_base` is taxable income (Form 1040 line 15) for the regular tax + and Form 6251 line 6 for the AMT. A Form 2555 filer has a capital gain + excess when worksheet line 10 is more than it, and then completes the + worksheet a second time with four modifications, which carry out 26 + U.S.C. 911(f)(2): + 1. line 9 is reduced, but not below zero, by the excess; + 2. line 6 is reduced, but not below zero, by the excess not used in (1); + 3. Schedule D line 18 (28% rate gain) is reduced, but not below zero, by + the excess; + 4. the excess enters line 16 of the Unrecaptured Section 1250 Gain + Worksheet as a loss, which reduces Schedule D line 19 by the part of + the excess the 28% rate gain did not absorb. + Sources: 2025 Form 1040 instructions, Foreign Earned Income Tax + Worksheet—Line 16, footnote; 2025 Form 6251 instructions, Part III, + "Form 2555". + + The excess is measured as the worksheets measure it, from line 10. The + section 1(h) formulas use section_911_capital_gain_excess, measured from + net_capital_gain. On a return the two are the same amount; in the model + dwks10 and net_capital_gain are separate formulas that can disagree (they + read different Form 4952 inputs). + + Returns the excess, worksheet lines 9, 10 and 13 and Schedule D line 19. + Without an excess these are the first worksheet's amounts, unchanged. + """ + line_9 = tax_unit("dwks09", period) + line_10 = tax_unit("dwks10", period) + line_13 = tax_unit("dwks13", period) + excludes_income = tax_unit("foreign_earned_income_exclusion", period) > 0 + capital_gain_excess = where( + excludes_income, max_(0, line_10 - max_(0, taxable_base)), 0 + ) + has_excess = capital_gain_excess > 0 + unrecaptured_gain = tax_unit("unrecaptured_section_1250_gain", period) + rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) + # Modifications 1 and 2. + reduced_line_9 = max_(0, line_9 - capital_gain_excess) + excess_after_line_9 = max_(0, capital_gain_excess - line_9) + line_6 = tax_unit("dividend_income_reduced_by_investment_income", period) + reduced_line_6 = max_(0, line_6 - excess_after_line_9) + reduced_line_10 = reduced_line_6 + reduced_line_9 + # Modifications 3 and 4. section_911_28_percent_rate_gain and + # section_911_unrecaptured_section_1250_gain apply the regular tax excess; + # this helper also runs with the AMT excess, so it applies them itself. + reduced_rate_gain = max_(0, rate_gain - capital_gain_excess) + reduced_unrecaptured_gain = max_( + 0, unrecaptured_gain - max_(0, capital_gain_excess - rate_gain) + ) + # Lines 11 to 13, as in dwks13. + reduced_line_11 = reduced_unrecaptured_gain + reduced_rate_gain + reduced_line_12 = min_(reduced_line_9, reduced_line_11) + reduced_line_13 = (reduced_line_10 - reduced_line_12) * tax_unit( + "has_qdiv_or_ltcg", period + ) + return ScheduleDTaxWorksheet( + capital_gain_excess=capital_gain_excess, + line_9=where(has_excess, reduced_line_9, line_9), + line_10=where(has_excess, reduced_line_10, line_10), + line_13=where(has_excess, reduced_line_13, line_13), + unrecaptured_section_1250_gain=where( + has_excess, reduced_unrecaptured_gain, unrecaptured_gain + ), + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.py new file mode 100644 index 00000000000..7c2d15b77a5 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_28_percent_rate_gain.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class section_911_28_percent_rate_gain(Variable): + value_type = float + entity = TaxUnit + label = "28-percent rate gain less the section 911 capital gain excess" + unit = USD + documentation = ( + "28-percent rate gain for the regular tax rates. A taxpayer with a " + "section 911 capital gain excess adds the excess to the losses " + "subtracted in figuring 28-percent rate gain, which reduces the gain " + "by the excess, but not below zero. Equals 28-percent rate gain for " + "everyone else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(A)(iii)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A_iii", + ), + dict( + title="26 U.S. Code § 1(h)(4)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_4", + ), + dict( + title="2025 Form 1040 instructions, Foreign Earned Income Tax Worksheet—Line 16, footnote, modification 3", + href="https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ), + ] + + def formula(tax_unit, period, parameters): + rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) + excess = tax_unit("section_911_capital_gain_excess", period) + return where(excess > 0, max_(0, rate_gain - excess), rate_gain) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.py new file mode 100644 index 00000000000..f73b00b88bf --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_adjusted_net_capital_gain.py @@ -0,0 +1,52 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.foreign_earned_income_exclusion.section_911_net_capital_gain_other_than_dividends import ( + section_911_net_capital_gain_other_than_dividends, +) + + +class section_911_adjusted_net_capital_gain(Variable): + value_type = float + entity = TaxUnit + label = "Adjusted net capital gain less the section 911 capital gain excess" + unit = USD + documentation = ( + "Adjusted net capital gain for the regular tax rates, figured from net " + "capital gain, qualified dividends, unrecaptured section 1250 gain and " + "28-percent rate gain as each is reduced by a section 911 capital gain " + "excess. Equals adjusted net capital gain for everyone else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(A)(iii)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A_iii", + ), + dict( + title="26 U.S. Code § 1(h)(3)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_3", + ), + ] + + def formula(tax_unit, period, parameters): + excess = tax_unit("section_911_capital_gain_excess", period) + gain_other_than_dividends = section_911_net_capital_gain_other_than_dividends( + tax_unit, period + ) + qualified_dividends = tax_unit("section_911_qualified_dividend_income", period) + unrecaptured_gain = tax_unit( + "section_911_unrecaptured_section_1250_gain", period + ) + rate_gain = tax_unit("section_911_28_percent_rate_gain", period) + # As in adjusted_net_capital_gain: net capital gain determined without + # regard to section 1(h)(11), reduced (not below zero) by unrecaptured + # section 1250 gain and 28-percent rate gain, plus qualified dividends. + # With an excess it is figured from these parts, so an + # adjusted_net_capital_gain input is not used. + reduced_gain = max_( + gain_other_than_dividends - (unrecaptured_gain + rate_gain), 0 + ) + return where( + excess > 0, + reduced_gain + qualified_dividends, + tax_unit("adjusted_net_capital_gain", period), + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py new file mode 100644 index 00000000000..a024c8c3860 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_capital_gain_excess.py @@ -0,0 +1,38 @@ +from policyengine_us.model_api import * + + +class section_911_capital_gain_excess(Variable): + value_type = float + entity = TaxUnit + label = "Section 911 capital gain excess" + unit = USD + documentation = ( + "For a taxpayer excluding foreign earned income under 26 U.S.C. " + "911(a), the excess of net capital gain over taxable income, " + "determined without regard to section 911(f). In figuring the tax on " + "taxable income plus the excluded amount, the capital gains are " + "reduced by this excess, so that the excluded amount is never taxed " + "at the capital gains rates. The section 1(h) formulas use this " + "amount; the Schedule D Tax Worksheet lines (dwks14, dwks19, " + "regular_tax_before_credits) measure the excess from worksheet line " + "10, as the worksheet does. On a return the two are the same amount; " + "in the model dwks10 and net_capital_gain are separate formulas that " + "can disagree." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(A)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A", + ), + dict( + title="2025 Form 1040 instructions, Foreign Earned Income Tax Worksheet—Line 16, footnote", + href="https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ), + ] + + def formula(tax_unit, period, parameters): + excludes_income = tax_unit("foreign_earned_income_exclusion", period) > 0 + net_capital_gain = tax_unit("net_capital_gain", period) + taxable_income = tax_unit("taxable_income", period) + return where(excludes_income, max_(0, net_capital_gain - taxable_income), 0) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.py new file mode 100644 index 00000000000..a112776154c --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class section_911_net_capital_gain(Variable): + value_type = float + entity = TaxUnit + label = "Net capital gain less the section 911 capital gain excess" + unit = USD + documentation = ( + "Net capital gain, including qualified dividends, for the regular tax " + "rates. A taxpayer with a section 911 capital gain excess reduces the " + "gain other than qualified dividends by the excess, then the " + "qualified dividends by the rest, which leaves net capital gain equal " + "to taxable income. Equals net capital gain for everyone else." + ) + definition_period = YEAR + reference = dict( + title="26 U.S. Code § 911(f)(2)(A)(i)-(ii)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A", + ) + + def formula(tax_unit, period, parameters): + net_capital_gain = tax_unit("net_capital_gain", period) + excess = tax_unit("section_911_capital_gain_excess", period) + return where(excess > 0, max_(0, net_capital_gain - excess), net_capital_gain) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py new file mode 100644 index 00000000000..c66190cdf30 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_net_capital_gain_other_than_dividends.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +def section_911_net_capital_gain_other_than_dividends(tax_unit, period): + """Net capital gain determined without regard to section 1(h)(11), for the + regular tax rates: reduced first, but not below zero, by any section 911 + capital gain excess (26 U.S.C. 911(f)(2)(A)(i)). + + Computed in place rather than stored as a variable: a stored amount is + rounded to single precision, which moved capital_gains_tax by fractions + of a cent for filers with nothing excluded. + """ + qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + gain = max_(0, tax_unit("net_capital_gain", period) - qualified_dividends) + excess = tax_unit("section_911_capital_gain_excess", period) + return where(excess > 0, max_(0, gain - excess), gain) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py new file mode 100644 index 00000000000..1864f9fb7f3 --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_qualified_dividend_income.py @@ -0,0 +1,39 @@ +from policyengine_us.model_api import * + + +class section_911_qualified_dividend_income(Variable): + value_type = float + entity = TaxUnit + label = "Qualified dividends less the section 911 capital gain excess" + unit = USD + documentation = ( + "Qualified dividend income for the regular tax rates. A taxpayer with " + "a section 911 capital gain excess reduces qualified dividends by the " + "part of the excess that exceeds net capital gain other than " + "qualified dividends. Equals qualified dividend income for everyone " + "else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(A)(ii)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A_ii", + ), + dict( + title="2025 Form 1040 instructions, Foreign Earned Income Tax Worksheet—Line 16, footnote, modification 2", + href="https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ), + ] + + def formula(tax_unit, period, parameters): + qualified_dividends = add(tax_unit, period, ["qualified_dividend_income"]) + net_capital_gain = tax_unit("net_capital_gain", period) + excess = tax_unit("section_911_capital_gain_excess", period) + # Net capital gain determined without regard to section 1(h)(11). + gain_other_than_dividends = max_(0, net_capital_gain - qualified_dividends) + excess_over_other_gain = max_(0, excess - gain_other_than_dividends) + return where( + excess > 0, + max_(0, qualified_dividends - excess_over_other_gain), + qualified_dividends, + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py new file mode 100644 index 00000000000..ca4e8230efb --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/section_911_unrecaptured_section_1250_gain.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class section_911_unrecaptured_section_1250_gain(Variable): + value_type = float + entity = TaxUnit + label = "Unrecaptured section 1250 gain less the section 911 capital gain excess" + unit = USD + documentation = ( + "Unrecaptured section 1250 gain for the regular tax rates. A taxpayer " + "with a section 911 capital gain excess adds the excess to the losses " + "of section 1(h)(4)(B). The part of the excess that the 28-percent " + "rate gain does not absorb then reduces unrecaptured section 1250 " + "gain, but not below zero. Equals unrecaptured section 1250 gain for " + "everyone else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(2)(A)(iii)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_2_A_iii", + ), + dict( + title="26 U.S. Code § 1(h)(6)(A)", + href="https://www.law.cornell.edu/uscode/text/26/1#h_6_A", + ), + dict( + title="2025 Form 1040 instructions, Foreign Earned Income Tax Worksheet—Line 16, footnote, modification 4", + href="https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ), + ] + + def formula(tax_unit, period, parameters): + unrecaptured_gain = tax_unit("unrecaptured_section_1250_gain", period) + rate_gain = tax_unit("capital_gains_28_percent_rate_gain", period) + excess = tax_unit("section_911_capital_gain_excess", period) + excess_over_rate_gain = max_(0, excess - rate_gain) + return where( + excess > 0, + max_(0, unrecaptured_gain - excess_over_rate_gain), + unrecaptured_gain, + ) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.py b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.py new file mode 100644 index 00000000000..1a2b155af1d --- /dev/null +++ b/policyengine_us/variables/gov/irs/tax/federal_income/foreign_earned_income_exclusion/taxable_income_plus_section_911_exclusion.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class taxable_income_plus_section_911_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "Taxable income plus the section 911 exclusion" + unit = USD + documentation = ( + "Taxable income increased by the foreign earned income and housing " + "amounts excluded under 26 U.S.C. 911(a). The regular tax of a " + "taxpayer excluding foreign earned income is the tax on this amount " + "less the tax on the excluded amount alone. Line 3 of the Foreign " + "Earned Income Tax Worksheet; equals taxable income for everyone else." + ) + definition_period = YEAR + reference = [ + dict( + title="26 U.S. Code § 911(f)(1)(A)(i)", + href="https://www.law.cornell.edu/uscode/text/26/911#f_1_A_i", + ), + dict( + title="2025 Form 1040 instructions, Foreign Earned Income Tax Worksheet—Line 16, line 3", + href="https://www.irs.gov/pub/irs-pdf/i1040gi.pdf#page=37", + ), + ] + + def formula(tax_unit, period, parameters): + taxable_income = tax_unit("taxable_income", period) + # Worksheet line 2c: "If zero or less, enter -0-". + excluded = max_(0, tax_unit("foreign_earned_income_exclusion", period)) + return taxable_income + excluded diff --git a/pyproject.toml b/pyproject.toml index 6c9277c6e0a..bfd691367ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dev = [ "setuptools>=80.9.0", "build>=1.2.2.post1", "towncrier>=24.8.0", + "hypothesis>=6.100.0", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index e9abc858340..3b54a463ea2 100644 --- a/uv.lock +++ b/uv.lock @@ -766,6 +766,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ab/443208c2988a24eaafbfbd6791b897ad9a8a1dd7f870d5b455d7d15a28ad/hypothesis-6.168.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9029775dc2e25e3e8e596c4306926f0079158353631c7c52c285bbc5c8073c2b", size = 792239, upload-time = "2026-09-28T05:19:59.144Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7b/619c1a76c52de2d3cfd68a2f9876c09464d63097db5bf86a28b62dc37bb1/hypothesis-6.168.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e1d91530cefdb9e0b6467c203eb509c61a11d70480a37db59cb53a2f9913102", size = 788113, upload-time = "2026-09-28T05:19:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/33841e8833105f850214a486d83740ba52041892ca94e687a05829c0ce83/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0df00cbe8133aa220308d11fa28e3add996cf5102a39b279c1d711015fd9114b", size = 1124241, upload-time = "2026-09-28T05:18:37.589Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/c84a7900feced6a420e824ab61d316e4f59a6887d991bc143ecc294a09ed/hypothesis-6.168.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7f75a2e23288ee82ee965a952473d9c5447c2cbc1afc94be09d2402201774e", size = 1170406, upload-time = "2026-09-28T05:19:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/47/5d/6e2a32b9c3020af7a06b00fe446a2c5155ae76ac468c0b68d7188e524002/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc2b1c37631f2231dd0d077225aa5908fc2bd34a4c3faf475053b0bc2eb24c95", size = 1300339, upload-time = "2026-09-28T05:20:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f9/78/19f3c9eb9a5c215b9fbde57be18ef7c1c78afa509b460d4e8f86026d741a/hypothesis-6.168.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:73960a58f6efc8cbc57d8ad0647955f7c5b25f3d562e3eaa57afa1c69894f7aa", size = 1336484, upload-time = "2026-09-28T05:19:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/88/bb/d46c4519e989e022667705d8a56e83a09ad4ea50eae07ca4bb0f4708dd23/hypothesis-6.168.3-cp311-cp311-win_amd64.whl", hash = "sha256:3f1122223759acc0fe5301c1ae505d762ee61af91db7b191a791ccdbcd965462", size = 684646, upload-time = "2026-09-28T05:20:00.842Z" }, + { url = "https://files.pythonhosted.org/packages/7a/56/44a17266bcf1add5fba7c3df5c3920396a5e924d917fb0534a6f9e2b9b9a/hypothesis-6.168.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bff12e036b67abc5ded682f724f4d7afaff5223190270a0763695f218c079068", size = 793313, upload-time = "2026-09-28T05:20:22.045Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/1a25b11e9133cb54c02ee6cf99a61296ad461aabc7aae3399e1d0ff1f095/hypothesis-6.168.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73c6188056e6dc110260e39d141c69c3d02f23e3ba1d8610c7a8b6f2510ec96", size = 784855, upload-time = "2026-09-28T05:19:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/8b/da/728d3210f0b0089337f37206a100d31ef546d1f99839e709f0a9ca443f8e/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb1063cb794765097b8c0add598bd34a904f44dc15db81275dd1f0ed1ac567ed", size = 1123043, upload-time = "2026-09-28T05:19:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/53/86/bfb9ec344a8b3ef2fa6f59bb3d30c5b3a7d86d594107670539c34ee53d1b/hypothesis-6.168.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b2d47f5d9060b049039bef0b267e88480f6468f95ee394e514e84a81ccdc5f5", size = 1169121, upload-time = "2026-09-28T05:20:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e0/5a76dbafbcaf8fff075f09377cddefa17289ef05dbf51878dde88385b966/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8367f623a98cbf90f33fd2a43b100671b152b5975bc343a578290945f52f7018", size = 1298749, upload-time = "2026-09-28T05:19:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/28/15/0e65af96b353f2c4bdc283c53ed0980e5ac5009afe97ef3b23c77b364664/hypothesis-6.168.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd81241f4cd76fb18d481e87b9688b30a0cb5c30841730513323ba1c7aba1837", size = 1335271, upload-time = "2026-09-28T05:20:54.005Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c5/37eab2155664aa544df321f6d3940b89c6702d79a72fdc98d7b5a2286f8c/hypothesis-6.168.3-cp312-cp312-win_amd64.whl", hash = "sha256:377438de53afb94347d9845b7d90db5905c6d2a8d612108e9938e0f80503bb90", size = 682211, upload-time = "2026-09-28T05:18:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/27960db1d45a94497e1681fb9d7b9140b44da4e655ba20f0d82f95d10230/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:101531b4ccf7fa12965a6b295d10b64c2f8a8fab61e1f9b18c55335d8fb02575", size = 793135, upload-time = "2026-09-28T05:19:14.056Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cb/be08379286c1925fcd651b92ed835f470a83b859b5c484b3431da9d851f5/hypothesis-6.168.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:41e0120814de5c3a6b58d8cb27cb11b8873ab33fba130cd09855a27f5a12a1ca", size = 788995, upload-time = "2026-09-28T05:20:34.164Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/6d885d139b2ed76e2f4908cf10eae56acc73bf05cbef286357c4559c9a04/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d120009d145697909f2f0c532f2d568558ddd6990707f8cba6576bfb9770696", size = 1124974, upload-time = "2026-09-28T05:19:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/b302ae809b48254830cae57801d0af0b66783df515b5dd56b9f21c8dfa86/hypothesis-6.168.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818b3d09bba60ef90463e47a4bc87275dba1e8aeb184d3102888440ca7c7837d", size = 1171967, upload-time = "2026-09-28T05:20:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ab/51031080a6600f5b07adf5f9dfa117345c2adda07a5c24bda523e70bd1e9/hypothesis-6.168.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9e3c36eba80e089ecfa28ec08049723223cb41d3cfe4d9988169cf98440e402b", size = 685641, upload-time = "2026-09-28T05:19:44.609Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1746,6 +1834,7 @@ dev = [ { name = "build" }, { name = "coverage" }, { name = "furo" }, + { name = "hypothesis" }, { name = "jupyter-book" }, { name = "ruff" }, { name = "setuptools" }, @@ -1757,6 +1846,7 @@ requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = ">=1.2.2.post1" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.9.2" }, { name = "furo", marker = "extra == 'dev'", specifier = ">=2024.8.6" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.100.0" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" },