diff --git a/changelog.d/fix-individual-eitc-reform.fixed.md b/changelog.d/fix-individual-eitc-reform.fixed.md new file mode 100644 index 00000000000..0a3bfaf23e2 --- /dev/null +++ b/changelog.d/fix-individual-eitc-reform.fixed.md @@ -0,0 +1 @@ +Fixed individual_eitc (Winship) reform not responding to parameter changes in the app. diff --git a/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml b/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml index 9a8210021bd..99787df1ea6 100644 --- a/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml +++ b/policyengine_us/parameters/gov/contrib/individual_eitc/agi_eitc_limit.yaml @@ -1,8 +1,11 @@ -description: Tax filers with combined earned income over this cannot claim the EITC. This is only active if the Winship EITC reform is active. +description: >- + Tax filers with combined AGI at or above this amount cannot claim the individual-income EITC. + Set to 0 for no AGI limit (default behavior under the Winship proposal). values: - 2020-01-01: 0 + 2023-01-01: 0 + metadata: - label: EITC AGI limit + label: Individual-income EITC AGI limit unit: currency-USD period: year reference: diff --git a/policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml b/policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml similarity index 58% rename from policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml rename to policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml index 75a13f2cf9c..03181a6f35b 100644 --- a/policyengine_us/parameters/gov/contrib/individual_eitc/enabled.yaml +++ b/policyengine_us/parameters/gov/contrib/individual_eitc/in_effect.yaml @@ -1,9 +1,11 @@ -description: A proposal by Scott Winship to assess EITC income at the individual level, regardless of filing status. +description: The individual-income EITC reform (Winship proposal) applies if this is true. values: - 2020-01-01: false + 0000-01-01: false + metadata: - label: Individual-income EITCs + label: Individual-income EITC reform in effect unit: bool + period: year reference: - title: Reforming the EITC to Reduce Single Parenthood and Ease Work-Family Balance href: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance diff --git a/policyengine_us/reforms/winship.py b/policyengine_us/reforms/winship.py index 4e9c0d5962f..0d22a926537 100644 --- a/policyengine_us/reforms/winship.py +++ b/policyengine_us/reforms/winship.py @@ -1,78 +1,116 @@ from policyengine_us.model_api import * +from policyengine_core.periods import period as period_ -def create_eitc_winship_reform(parameters, period, bypass=False): - if not bypass and not parameters(period).gov.contrib.individual_eitc.enabled: - return None +def create_individual_eitc() -> Reform: + """Individual-income EITC reform (Winship proposal). + + Computes EITC separately for each spouse based on their individual + earnings, then sums the results. This differs from baseline EITC + which uses combined household earnings. - # Compute EITC under filer_adj_earnings = filer head adj earnings - # Then compute EITC under filer_adj_earnings = filer spouse adj earnings - # Then set EITC = sum of the two + Reference: https://ifstudies.org/blog/reforming-the-eitc-to-reduce-single-parenthood-and-ease-work-family-balance + """ - class original_eitc(Variable): + class eitc(Variable): value_type = float entity = TaxUnit definition_period = YEAR - label = "Original EITC" + label = "Federal earned income credit" reference = "https://www.law.cornell.edu/uscode/text/26/32#a" unit = USD defined_for = "eitc_eligible" def formula(tax_unit, period, parameters): + p = parameters(period).gov.contrib.individual_eitc + takes_up_eitc = tax_unit("takes_up_eitc", period) + + # Check if reform is active + reform_active = p.in_effect + + # Baseline EITC calculation maximum = tax_unit("eitc_maximum", period) phased_in = tax_unit("eitc_phased_in", period) reduction = tax_unit("eitc_reduction", period) limitation = max_(0, maximum - reduction) - return min_(phased_in, limitation) + baseline_eitc = min_(phased_in, limitation) - class eitc(Variable): - value_type = float - entity = TaxUnit - definition_period = YEAR - label = "EITC" - unit = USD - defined_for = "eitc_eligible" - - def formula(tax_unit, period, parameters): + # Reform: compute EITC separately for head and spouse person = tax_unit.members - simulation = tax_unit.simulation - agi = tax_unit("adjusted_gross_income", period) adj_earnings = person("adjusted_earnings", period) is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) - filer_earned_head_only = tax_unit.sum(adj_earnings * is_head) - filer_earned_spouse_only = tax_unit.sum(adj_earnings * is_spouse) - - head_only_branch = simulation.get_branch("head_only") - head_only_branch.set_input( - "filer_adjusted_earnings", period, filer_earned_head_only - ) - # Phase out with respect to individual earnings instead of AGI - head_only_branch.set_input( - "adjusted_gross_income", period, filer_earned_head_only - ) - head_eitc = head_only_branch.calculate("original_eitc", period) - - spouse_only_branch = simulation.get_branch("spouse_only") - spouse_only_branch.set_input( - "filer_adjusted_earnings", period, filer_earned_spouse_only - ) - spouse_only_branch.set_input( - "adjusted_gross_income", period, filer_earned_spouse_only - ) - spouse_eitc = spouse_only_branch.calculate("original_eitc", period) - - agi_limit = parameters(period).gov.contrib.individual_eitc.agi_eitc_limit - - return (agi < agi_limit) * (head_eitc + spouse_eitc) - - class winship_eitc_reform(Reform): + head_earnings = tax_unit.sum(adj_earnings * is_head) + spouse_earnings = tax_unit.sum(adj_earnings * is_spouse) + + # Get EITC parameters for direct computation + child_count = tax_unit("eitc_child_count", period) + eitc_params = parameters(period).gov.irs.credits.eitc + + eitc_maximum = eitc_params.max.calc(child_count) + phase_in_rate = eitc_params.phase_in_rate.calc(child_count) + phase_out_rate = eitc_params.phase_out.rate.calc(child_count) + # Use joint phase-out start (matching original behavior) + phase_out_start = eitc_params.phase_out.start.calc( + child_count + ) + eitc_params.phase_out.joint_bonus.calc(child_count) + + # Compute head's individual EITC + head_phased_in = min_(eitc_maximum, head_earnings * phase_in_rate) + head_phase_out = max_(0, head_earnings - phase_out_start) + head_reduction = phase_out_rate * head_phase_out + head_limitation = max_(0, eitc_maximum - head_reduction) + head_eitc = min_(head_phased_in, head_limitation) + + # Compute spouse's individual EITC + spouse_phased_in = min_(eitc_maximum, spouse_earnings * phase_in_rate) + spouse_phase_out = max_(0, spouse_earnings - phase_out_start) + spouse_reduction = phase_out_rate * spouse_phase_out + spouse_limitation = max_(0, eitc_maximum - spouse_reduction) + spouse_eitc = min_(spouse_phased_in, spouse_limitation) + + individual_eitc = head_eitc + spouse_eitc + + # Apply AGI limit if set (0 = no limit) + agi_limit = p.agi_eitc_limit + agi = tax_unit("adjusted_gross_income", period) + agi_eligible = where(agi_limit > 0, agi < agi_limit, True) + + reform_eitc = individual_eitc * agi_eligible + + return where(reform_active, reform_eitc, baseline_eitc) * takes_up_eitc + + class reform(Reform): def apply(self): - self.update_variable(original_eitc) self.update_variable(eitc) - return winship_eitc_reform + return reform + + +def create_individual_eitc_reform(parameters, period, bypass: bool = False): + if bypass: + return create_individual_eitc() + + p = parameters.gov.contrib.individual_eitc + + reform_active = False + current_period = period_(period) + + for _ in range(5): + if p(current_period).in_effect: + reform_active = True + break + current_period = current_period.offset(1, "year") + + if reform_active: + return create_individual_eitc() + else: + return None + +individual_eitc_reform = create_individual_eitc_reform(None, None, bypass=True) -winship_reform = create_eitc_winship_reform(None, None, bypass=True) +# Backward compatibility aliases +winship_reform = individual_eitc_reform +create_eitc_winship_reform = create_individual_eitc_reform diff --git a/policyengine_us/tests/policy/reform/winship.yaml b/policyengine_us/tests/policy/reform/winship.yaml index e171047a219..02adc7dc37b 100644 --- a/policyengine_us/tests/policy/reform/winship.yaml +++ b/policyengine_us/tests/policy/reform/winship.yaml @@ -1,72 +1,152 @@ -- name: Winship reform gives a bonus +# Individual-income EITC (Winship) reform tests +# Tests that EITC is calculated separately for head and spouse earnings + +- name: Case 1, individual EITC gives bonus for dual-earner couple. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 30_000 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: - eitc: 6210 + 6604 # The sum of the EITCs for the two tests below. - original_eitc: 2_000 + # Sum of individual EITCs: head (20k income) + spouse (30k income) + # = 6604 + 6210 = 12814 + eitc: 12_814 -- name: EITC for 30k/0k income, to inform the first test. +- name: Case 2, EITC for 30k head income only. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 30_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: eitc: 6_210 -- name: EITC for 20k/0k income, to inform the first test. +- name: Case 3, EITC for 20k head income only. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: + gov.contrib.individual_eitc.in_effect: true gov.contrib.individual_eitc.agi_eitc_limit: 100_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: eitc: 6_604 -- name: EITC for 20k/0k income, to inform the first test. +- name: Case 4, AGI limit blocks EITC when AGI exceeds limit. period: 2023 absolute_error_margin: 10 reforms: policyengine_us.reforms.winship.winship_reform input: - gov.contrib.individual_eitc.agi_eitc_limit: 19_000 + gov.contrib.individual_eitc.in_effect: true + gov.contrib.individual_eitc.agi_eitc_limit: 19_000 people: - head: + person1: employment_income: 20_000 - spouse: + is_tax_unit_head: true + person2: + employment_income: 0 + is_tax_unit_spouse: true + person3: + age: 5 + person4: + age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] + output: + # AGI (20k) >= limit (19k), so no EITC + eitc: 0 + +- name: Case 5, reform disabled returns baseline EITC. + period: 2023 + absolute_error_margin: 10 + reforms: policyengine_us.reforms.winship.winship_reform + input: + gov.contrib.individual_eitc.in_effect: false + people: + person1: + employment_income: 20_000 + is_tax_unit_head: true + person2: + employment_income: 30_000 + is_tax_unit_spouse: true + person3: + age: 5 + person4: + age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] + output: + # Baseline EITC for combined 50k income with 2 children + # Should be much lower than individual EITC + eitc: 2_000 + +- name: Case 6, no AGI limit when set to 0. + period: 2023 + absolute_error_margin: 10 + reforms: policyengine_us.reforms.winship.winship_reform + input: + gov.contrib.individual_eitc.in_effect: true + gov.contrib.individual_eitc.agi_eitc_limit: 0 + people: + person1: + employment_income: 100_000 + is_tax_unit_head: true + person2: employment_income: 0 - child_1: + is_tax_unit_spouse: true + person3: age: 5 - child_2: + person4: age: 6 + tax_units: + tax_unit: + members: [person1, person2, person3, person4] output: + # AGI limit of 0 means no limit - should still get individual EITC + # Head with 100k income is past EITC phase-out, so 0 eitc: 0