diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb2d..3d95cfa030b 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,5 @@ +- bump: minor + changes: + added: + - Add Streamlined EITC reform with filing status dimension for max credit + - Add CTC Linear Phase-Out reform for complete phase-out between thresholds diff --git a/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/end.yaml b/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/end.yaml new file mode 100644 index 00000000000..e948946819b --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/end.yaml @@ -0,0 +1,19 @@ +description: The CTC fully phases out at this income, by filing status. + +SINGLE: + 2020-01-01: 240_000 +JOINT: + 2020-01-01: 440_000 +SEPARATE: + 2020-01-01: 175_000 +HEAD_OF_HOUSEHOLD: + 2020-01-01: 240_000 +SURVIVING_SPOUSE: + 2020-01-01: 440_000 + +metadata: + unit: currency-USD + period: year + label: CTC full phase-out income + breakdown: + - filing_status diff --git a/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/in_effect.yaml b/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/in_effect.yaml new file mode 100644 index 00000000000..0150990f836 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/ctc/linear_phase_out/in_effect.yaml @@ -0,0 +1,9 @@ +description: The CTC linear phase-out reform is in effect if this is true. + +values: + 2020-01-01: false + +metadata: + unit: bool + period: year + label: CTC linear phase-out in effect diff --git a/policyengine_us/parameters/gov/contrib/streamlined_eitc/in_effect.yaml b/policyengine_us/parameters/gov/contrib/streamlined_eitc/in_effect.yaml new file mode 100644 index 00000000000..a9bd90dc496 --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/streamlined_eitc/in_effect.yaml @@ -0,0 +1,9 @@ +description: The streamlined EITC reform is in effect if this is true. + +values: + 2020-01-01: false + +metadata: + unit: bool + period: year + label: Streamlined EITC in effect diff --git a/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/joint.yaml b/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/joint.yaml new file mode 100644 index 00000000000..170f24d3e6d --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/joint.yaml @@ -0,0 +1,18 @@ +description: The EITC is capped at this amount for joint filers, based on number of children. + +brackets: + - threshold: + 2020-01-01: 0 + amount: + 2020-01-01: 0 + - threshold: + 2020-01-01: 1 + amount: + 2020-01-01: 4_993 + +metadata: + type: single_amount + period: year + threshold_unit: child + amount_unit: currency-USD + label: Streamlined EITC maximum (joint filers) diff --git a/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/single.yaml b/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/single.yaml new file mode 100644 index 00000000000..383fbfa36cc --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/streamlined_eitc/max/single.yaml @@ -0,0 +1,18 @@ +description: The EITC is capped at this amount for single and head of household filers, based on number of children. + +brackets: + - threshold: + 2020-01-01: 0 + amount: + 2020-01-01: 0 + - threshold: + 2020-01-01: 1 + amount: + 2020-01-01: 3_995 + +metadata: + type: single_amount + threshold_unit: child + amount_unit: currency-USD + label: Streamlined EITC maximum (single/HOH filers) + period: year \ No newline at end of file diff --git a/policyengine_us/reforms/ctc/__init__.py b/policyengine_us/reforms/ctc/__init__.py index cb3ae492f9d..cbaaf03fb87 100644 --- a/policyengine_us/reforms/ctc/__init__.py +++ b/policyengine_us/reforms/ctc/__init__.py @@ -13,3 +13,6 @@ from .ctc_minimum_refundable_amount import ( create_ctc_minimum_refundable_amount_reform, ) +from .ctc_linear_phase_out import ( + create_ctc_linear_phase_out_reform, +) diff --git a/policyengine_us/reforms/ctc/ctc_linear_phase_out.py b/policyengine_us/reforms/ctc/ctc_linear_phase_out.py new file mode 100644 index 00000000000..9f7eb397357 --- /dev/null +++ b/policyengine_us/reforms/ctc/ctc_linear_phase_out.py @@ -0,0 +1,88 @@ +from policyengine_us.model_api import * +from policyengine_core.periods import period as period_ + + +def create_ctc_linear_phase_out() -> Reform: + """ + CTC Linear Phase-Out Reform: + - Replaces the standard CTC phase-out with a linear phase-out + - Uses existing IRS threshold for phase-out start + - Adds new parameter for phase-out end threshold by filing status + + To model full Enhanced CTC policy: + - Use this reform for linear phase-out + - Use ctc_minimum_refundable_amount reform for minimum refundability + - Set existing IRS parameters for credit amounts and phase-in rates + + This reform adds: + - gov.contrib.ctc.linear_phase_out.in_effect (reform switch) + - gov.contrib.ctc.linear_phase_out.end (full phase-out by filing status) + """ + + class ctc_phase_out(Variable): + value_type = float + entity = TaxUnit + label = "CTC reduction from income" + unit = USD + documentation = ( + "Reduction of the total CTC due to income with linear phase-out." + ) + definition_period = YEAR + + def formula(tax_unit, period, parameters): + # Linear phase-out between threshold and end + p = parameters(period).gov.contrib.ctc.linear_phase_out + ctc_irs = parameters(period).gov.irs.credits.ctc + filing_status = tax_unit("filing_status", period) + income = tax_unit("adjusted_gross_income", period) + + # Use existing IRS threshold for phase-out start + phase_out_threshold = ctc_irs.phase_out.threshold[filing_status] + # Use new parameter for phase-out end + phase_out_end = p.end[filing_status] + + # Calculate the maximum CTC for the tax unit + ctc_maximum = tax_unit("ctc_maximum_with_arpa_addition", period) + + # Calculate phase-out as linear reduction + phase_out_range = max_(1, phase_out_end - phase_out_threshold) + excess_income = max_(0, income - phase_out_threshold) + + # Phase-out rate = ctc_maximum / phase_out_range + phase_out_rate = ctc_maximum / phase_out_range + reduction = excess_income * phase_out_rate + return min_(reduction, ctc_maximum) + + class reform(Reform): + def apply(self): + self.update_variable(ctc_phase_out) + + return reform + + +def create_ctc_linear_phase_out_reform( + parameters, period, bypass: bool = False +): + if bypass: + return create_ctc_linear_phase_out() + + p = parameters.gov.contrib.ctc.linear_phase_out + + reform_active = False + current_period = period_(period) + + for i 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_ctc_linear_phase_out() + else: + return None + + +ctc_linear_phase_out = create_ctc_linear_phase_out_reform( + None, None, bypass=True +) diff --git a/policyengine_us/reforms/eitc/__init__.py b/policyengine_us/reforms/eitc/__init__.py index b7c008b5e95..441992a1e53 100644 --- a/policyengine_us/reforms/eitc/__init__.py +++ b/policyengine_us/reforms/eitc/__init__.py @@ -1,3 +1,6 @@ from .halve_joint_eitc_phase_out_rate import ( create_halve_joint_eitc_phase_out_rate_reform, ) +from .streamlined_eitc import ( + create_streamlined_eitc_reform, +) diff --git a/policyengine_us/reforms/eitc/streamlined_eitc.py b/policyengine_us/reforms/eitc/streamlined_eitc.py new file mode 100644 index 00000000000..bd23a7d9ec3 --- /dev/null +++ b/policyengine_us/reforms/eitc/streamlined_eitc.py @@ -0,0 +1,69 @@ +from policyengine_us.model_api import * +from policyengine_core.periods import period as period_ + + +def create_streamlined_eitc() -> Reform: + """ + Streamlined EITC Reform: + - Single schedule for all filers with dependent children (1+ children) + - Maximum credit varies by filing status: single vs married + + To model the full policy, set these existing IRS parameters: + - gov.irs.credits.eitc.phase_in_rate + - gov.irs.credits.eitc.phase_out.start (phase-out start for single) + - gov.irs.credits.eitc.phase_out.joint_bonus (additional amount for married) + - gov.irs.credits.eitc.phase_out.rate (phase-out rate) + + This reform adds: + - gov.contrib.streamlined_eitc.max.single (max credit for single/HOH) + - gov.contrib.streamlined_eitc.max.joint (max credit for married filing jointly) + """ + + class eitc_maximum(Variable): + value_type = float + entity = TaxUnit + label = "Maximum EITC" + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/32#a" + unit = USD + + def formula(tax_unit, period, parameters): + child_count = tax_unit("eitc_child_count", period) + p = parameters(period).gov.contrib.streamlined_eitc + filing_status = tax_unit("filing_status", period) + joint = filing_status == filing_status.possible_values.JOINT + return where( + joint, + p.max.joint.calc(child_count), + p.max.single.calc(child_count), + ) + + class reform(Reform): + def apply(self): + self.update_variable(eitc_maximum) + + return reform + + +def create_streamlined_eitc_reform(parameters, period, bypass: bool = False): + if bypass: + return create_streamlined_eitc() + + p = parameters.gov.contrib.streamlined_eitc + + reform_active = False + current_period = period_(period) + + for i 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_streamlined_eitc() + else: + return None + + +streamlined_eitc = create_streamlined_eitc_reform(None, None, bypass=True) diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 5c0a65cf3bf..b9406694263 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -19,6 +19,7 @@ ) from .biden.budget_2025 import create_capital_gains_tax_increase_reform from .eitc import create_halve_joint_eitc_phase_out_rate_reform +from .eitc import create_streamlined_eitc_reform from .states.ny.wftc import create_ny_working_families_tax_credit_reform from .harris.lift.middle_class_tax_credit import ( create_middle_class_tax_credit_reform, @@ -65,6 +66,7 @@ create_ctc_per_child_phase_in_reform, create_ctc_per_child_phase_out_reform, create_ctc_minimum_refundable_amount_reform, + create_ctc_linear_phase_out_reform, ) from .snap import ( create_abolish_snap_deductions_reform, @@ -298,6 +300,10 @@ def create_structural_reforms_from_parameters(parameters, period): aca_ptc_700_fpl_cliff = create_aca_ptc_700_fpl_cliff_reform( parameters, period ) + streamlined_eitc = create_streamlined_eitc_reform(parameters, period) + ctc_linear_phase_out = create_ctc_linear_phase_out_reform( + parameters, period + ) reforms = [ afa_reform, @@ -358,6 +364,8 @@ def create_structural_reforms_from_parameters(parameters, period): aca_ptc_additional_bracket, aca_ptc_simplified_bracket, aca_ptc_700_fpl_cliff, + streamlined_eitc, + ctc_linear_phase_out, ] reforms = tuple(filter(lambda x: x is not None, reforms)) diff --git a/policyengine_us/tests/policy/reform/ctc_linear_phase_out.yaml b/policyengine_us/tests/policy/reform/ctc_linear_phase_out.yaml new file mode 100644 index 00000000000..eeb2724b1da --- /dev/null +++ b/policyengine_us/tests/policy/reform/ctc_linear_phase_out.yaml @@ -0,0 +1,230 @@ +# CTC Linear Phase-Out Reform Tests +# Tests the structural reform for linear phase-out (CTC phases out completely +# between the IRS threshold and the end parameter) + +- name: Below phase-out threshold - full CTC + period: 2025 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 50_000 + age: 30 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + output: + # Below phase-out threshold ($200,000), no phase-out + ctc_phase_out: 0 + +- name: Phase-out at mid-range income - single filer + period: 2025 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 220_000 + age: 30 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + output: + # Phase-out threshold: $200,000 (default 2025) + # Phase-out range: $240,000 - $200,000 = $40,000 + # Excess income: $220,000 - $200,000 = $20,000 + # Phase-out rate: $2,200 / $40,000 = 0.055 + # Reduction: $20,000 * 0.055 = $1,100 + ctc_phase_out: 1_100 + +- name: Full phase-out beyond end threshold + period: 2025 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 250_000 + age: 30 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + output: + # Above $240,000, fully phased out + ctc_phase_out: 2_200 + +- name: Joint filers - higher phase-out end + period: 2025 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.JOINT: 440_000 + people: + head: + employment_income: 200_000 + age: 30 + spouse: + employment_income: 200_000 + age: 30 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + output: + # Phase-out threshold (JOINT): $400,000 (default 2025) + # Phase-out range: $440,000 - $400,000 = $40,000 + # Excess income: $400,000 - $400,000 = $0 + # No phase-out yet + ctc_phase_out: 0 + +- name: Joint filers - partial phase-out + period: 2025 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.JOINT: 440_000 + people: + head: + employment_income: 210_000 + age: 30 + spouse: + employment_income: 210_000 + age: 30 + child: + age: 3 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + output: + # Phase-out threshold (JOINT): $400,000 (default 2025) + # Phase-out range: $440,000 - $400,000 = $40,000 + # Excess income: $420,000 - $400,000 = $20,000 + # Phase-out rate: $2,200 / $40,000 = 0.055 + # Reduction: $20,000 * 0.055 = $1,100 + ctc_phase_out: 1_100 + +- name: Multiple children - linear phase-out + period: 2025 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 220_000 + age: 30 + child1: + age: 3 + is_tax_unit_dependent: true + child2: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child1, child2] + filing_status: SINGLE + output: + # Total CTC max: $2,200 + $2,200 = $4,400 + # Phase-out range: $240,000 - $200,000 = $40,000 + # Excess income: $220,000 - $200,000 = $20,000 + # Phase-out rate: $4,400 / $40,000 = 0.11 + # Reduction: $20,000 * 0.11 = $2,200 + ctc_phase_out: 2_200 + +- name: Exactly at phase-out threshold - zero reduction + period: 2025 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 200_000 + age: 30 + child: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + output: + # Income exactly at threshold ($200,000) + # Excess income: $200,000 - $200,000 = $0 + # No phase-out reduction + ctc_phase_out: 0 + +- name: Exactly at phase-out end - full reduction + period: 2025 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.SINGLE: 240_000 + people: + head: + employment_income: 240_000 + age: 30 + child: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + output: + # Income exactly at end ($240,000) + # Excess income: $240,000 - $200,000 = $40,000 + # Phase-out rate: $2,200 / $40,000 = 0.055 + # Reduction: $40,000 * 0.055 = $2,200 (full CTC) + ctc_phase_out: 2_200 + +- name: Head of household filing status + period: 2025 + absolute_error_margin: 1 + reforms: policyengine_us.reforms.ctc.ctc_linear_phase_out.ctc_linear_phase_out + input: + gov.contrib.ctc.linear_phase_out.in_effect: true + gov.contrib.ctc.linear_phase_out.end.HEAD_OF_HOUSEHOLD: 240_000 + people: + head: + employment_income: 220_000 + age: 35 + child: + age: 8 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: HEAD_OF_HOUSEHOLD + output: + # Phase-out threshold (HOH): $200,000 (default 2025) + # Phase-out range: $240,000 - $200,000 = $40,000 + # Excess income: $220,000 - $200,000 = $20,000 + # Phase-out rate: $2,200 / $40,000 = 0.055 + # Reduction: $20,000 * 0.055 = $1,100 + ctc_phase_out: 1_100 diff --git a/policyengine_us/tests/policy/reform/streamlined_eitc.yaml b/policyengine_us/tests/policy/reform/streamlined_eitc.yaml new file mode 100644 index 00000000000..fc4a2336089 --- /dev/null +++ b/policyengine_us/tests/policy/reform/streamlined_eitc.yaml @@ -0,0 +1,153 @@ +# Streamlined EITC Reform Tests +# Tests the structural reform that adds filing status dimension to max credit +# The reform uses contrib parameters that already have default values set + +- name: Single filer with 1 child - check child count + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 15_000 + age: 30 + child: + age: 5 + tax_units: + tax_unit: + members: [head, child] + filing_status: HEAD_OF_HOUSEHOLD + output: + eitc_child_count: 1 + eitc_maximum: 3_995 + +- name: Joint filer with 1 child - higher max credit + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 20_000 + age: 30 + spouse: + employment_income: 0 + age: 30 + child: + age: 5 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + output: + eitc_child_count: 1 + eitc_maximum: 4_993 + +- name: Single filer with 2 children - same max as 1 child + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 15_000 + age: 30 + child1: + age: 5 + child2: + age: 8 + tax_units: + tax_unit: + members: [head, child1, child2] + filing_status: HEAD_OF_HOUSEHOLD + output: + eitc_child_count: 2 + eitc_maximum: 3_995 + +- name: Childless single filer - zero max + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 15_000 + age: 30 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + output: + eitc_child_count: 0 + eitc_maximum: 0 + +- name: Childless joint filer - zero max despite filing status + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 15_000 + age: 30 + spouse: + employment_income: 10_000 + age: 30 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + # Joint filers get higher max only if they have children + # With zero children, max is still 0 + eitc_child_count: 0 + eitc_maximum: 0 + +- name: Joint filer with 3 children - same max as 1 child + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 25_000 + age: 35 + spouse: + employment_income: 15_000 + age: 35 + child1: + age: 3 + child2: + age: 7 + child3: + age: 12 + tax_units: + tax_unit: + members: [head, spouse, child1, child2, child3] + filing_status: JOINT + output: + # Streamlined EITC: same max for 1+ children + # Joint filers with children get $4,993 + eitc_child_count: 3 + eitc_maximum: 4_993 + +- name: Single filer at phase-out region - max unchanged + period: 2025 + reforms: policyengine_us.reforms.eitc.streamlined_eitc.streamlined_eitc + input: + gov.contrib.streamlined_eitc.in_effect: true + people: + head: + employment_income: 40_000 + age: 30 + child: + age: 8 + tax_units: + tax_unit: + members: [head, child] + filing_status: HEAD_OF_HOUSEHOLD + output: + # Even in phase-out region, eitc_maximum stays at $3,995 + # Phase-out affects eitc amount, not maximum + eitc_child_count: 1 + eitc_maximum: 3_995