diff --git a/changelog.d/fix-educator-expense-cap.fixed.md b/changelog.d/fix-educator-expense-cap.fixed.md new file mode 100644 index 00000000000..346e546903a --- /dev/null +++ b/changelog.d/fix-educator-expense-cap.fixed.md @@ -0,0 +1 @@ +Cap the federal educator expense deduction at each eligible educator's limit under 26 U.S.C. 62(a)(2)(D) and (d)(3): $250 through 2021, $300 for 2022 through 2025 and $350 for 2026, indexed after that. On a joint return each spouse is capped separately, and a tax unit dependent's expenses stay off the filer's return. Previously the raw expense input was deducted in full. diff --git a/policyengine_us/parameters/gov/irs/ald/deductions.yaml b/policyengine_us/parameters/gov/irs/ald/deductions.yaml index 9657a3ccb81..4a0f5b4740b 100644 --- a/policyengine_us/parameters/gov/irs/ald/deductions.yaml +++ b/policyengine_us/parameters/gov/irs/ald/deductions.yaml @@ -6,7 +6,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - capped_qualified_tuition_expenses_ald - domestic_production_ald - health_savings_account_ald @@ -23,7 +23,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - capped_qualified_tuition_expenses_ald - health_savings_account_ald - self_employed_health_insurance_ald @@ -39,7 +39,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald @@ -54,7 +54,7 @@ values: - student_loan_interest_ald - early_withdrawal_penalty - alimony_expense_ald - - educator_expense + - educator_expense_ald - health_savings_account_ald - self_employed_health_insurance_ald - self_employed_pension_contribution_ald diff --git a/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml new file mode 100644 index 00000000000..e3023b207cf --- /dev/null +++ b/policyengine_us/parameters/gov/irs/ald/educator_expense/cap.yaml @@ -0,0 +1,61 @@ +description: The IRS caps the educator expense deduction at this amount for each eligible educator. +values: + # 26 USC 62(a)(2)(D), added for 2002 by Pub. L. 107-147 and extended + # retroactively through 2015, when Pub. L. 114-113 made it permanent and + # indexed it from 2016 (26 USC 62(d)(3)). + 2002-01-01: 250 + 2016-01-01: 250 + 2017-01-01: 250 + 2018-01-01: 250 + 2019-01-01: 250 + 2020-01-01: 250 + 2021-01-01: 250 + 2022-01-01: 300 + 2023-01-01: 300 + 2024-01-01: 300 + 2025-01-01: 300 + 2026-01-01: 350 +metadata: + unit: currency-USD + period: year + label: Educator expense deduction cap + # Values after the last IRS publication are computed in + # parameters/uprating_extensions.py per 26 U.S.C. 62(d)(3): the statutory + # $250 increased by the 1(f)(3) cost-of-living adjustment with calendar + # year 2014 as the base year, each increase rounded to the nearest multiple + # of $50. Computing from the $250 base rather than chaining from the + # rounded $350 puts each projected $50 step in the year the statute does on + # the model's CPI projection; those values supersede this uprating rule, + # which describes the index and rounding. + uprating: + parameter: gov.irs.uprating + rounding: + type: nearest + interval: 50 + reference: + - title: 26 U.S. Code § 62(a)(2)(D) - Educator expenses + href: https://www.law.cornell.edu/uscode/text/26/62#a_2_D + - title: 26 U.S. Code § 62(d)(3) - Inflation adjustment + href: https://www.law.cornell.edu/uscode/text/26/62#d_3 + - title: 2016 Form 1040 instructions, line 23, educator expenses (2016) + href: https://www.irs.gov/pub/irs-prior/i1040gi--2016.pdf#page=31 + - title: Rev. Proc. 2016-55, section 3.12 (2017) + href: https://www.irs.gov/pub/irs-drop/rp-16-55.pdf#page=14 + - title: Rev. Proc. 2017-58, section 3.12 (2018) + href: https://www.irs.gov/pub/irs-drop/rp-17-58.pdf#page=12 + - title: Rev. Proc. 2018-57, section 3.14 (2019) + href: https://www.irs.gov/pub/irs-drop/rp-18-57.pdf#page=15 + - title: Rev. Proc. 2019-44, section 3.14 (2020) + href: https://www.irs.gov/pub/irs-drop/rp-19-44.pdf#page=12 + - title: Rev. Proc. 2020-45, section 3.14 (2021) + href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf#page=12 + - title: Rev. Proc. 2021-45, section 3.13 (2022) + href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf#page=13 + - title: Rev. Proc. 2022-38, section 3.13 (2023) + href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=12 + - title: Rev. Proc. 2023-34, section 3.13 (2024) + href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf#page=11 + - title: Rev. Proc. 2024-40, section 2.13 (2025) + href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=11 + - title: Rev. Proc. 2025-32, section 4.12 (2026) + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=17 diff --git a/policyengine_us/parameters/uprating_extensions.py b/policyengine_us/parameters/uprating_extensions.py index c9f376ce2fc..3cc564105a8 100644 --- a/policyengine_us/parameters/uprating_extensions.py +++ b/policyengine_us/parameters/uprating_extensions.py @@ -171,6 +171,38 @@ def extend_dependent_standard_deduction_parameters( ) +# 26 U.S.C. 62(d)(3) indexing of the 62(a)(2)(D) educator expense cap: +# statutory dollar amount and 1(f)(3) base year. +EDUCATOR_EXPENSE_CAP_STATUTORY_BASE = (250, 2014) + + +def extend_educator_expense_cap(parameters: ParameterNode, end_year: int) -> None: + """Project the per-educator cap on the educator expense deduction. + + 26 U.S.C. 62(d)(3) increases the $250 amount in 62(a)(2)(D) by the + 1(f)(3) cost-of-living adjustment, substituting calendar year 2014 for + 2016, and rounds the increase to the nearest multiple of $50. Each year + is computed from the statutory base rather than chained from the last + rounded value, which can put a $50 step a year or more early or late; + IRS values encoded in the YAML take precedence for their own years. + """ + ROUNDING_INTERVAL = 50 + base, base_year = EDUCATOR_EXPENSE_CAP_STATUTORY_BASE + cap = parameters.gov.irs.ald.educator_expense.cap + first_projected_year = 1 + max( + int(value.instant_str[:4]) for value in cap.values_list + ) + for year in range(first_projected_year, end_year + 1): + cola = get_irs_cola(parameters, year, base_year) + # Nearest multiple of $50; an exact half rounds up. + increase = math.floor(base * cola / ROUNDING_INTERVAL + 0.5) * ROUNDING_INTERVAL + cap.update(period=f"year:{year}-01-01:1", value=float(base + increase)) + cap.update( + start=instant(f"{end_year}-01-01"), + value=cap(f"{end_year}-01-01"), + ) + + def extend_or_ctc_parameters(parameters: ParameterNode, end_year: int) -> None: """Project the Oregon Kids' Credit amount and phase-out start. @@ -810,4 +842,9 @@ def set_all_uprating_parameters(parameters: ParameterNode) -> ParameterNode: # projected windows are available. extend_dependent_standard_deduction_parameters(parameters, end_year=END_YEAR) + # The educator expense cap follows 26 U.S.C. 62(d)(3)'s chained CPI-U + # schedule, computed from the statutory $250 and 2014 base year. Must run + # after the Chained CPI-U extension above. + extend_educator_expense_cap(parameters, end_year=END_YEAR) + return parameters diff --git a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py index b985bf626f1..70b816980e6 100644 --- a/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py +++ b/policyengine_us/tests/core/test_dependent_above_the_line_deductions_invariants.py @@ -69,7 +69,7 @@ "alimony_expense_ald_person", "student_loan_interest_ald", "early_withdrawal_penalty", - "educator_expense", + "educator_expense_ald_person", "traditional_ira_contributions", ] # Person-level amounts that are the filer's wherever they are recorded. @@ -82,6 +82,7 @@ "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", "alimony_expense_ald": "alimony_expense_ald_person", + "educator_expense_ald": "educator_expense_ald_person", } TAX_UNIT_OUTPUTS = [ "adjusted_gross_income", diff --git a/policyengine_us/tests/core/test_educator_expense_cap_invariants.py b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py new file mode 100644 index 00000000000..9883e61dc71 --- /dev/null +++ b/policyengine_us/tests/core/test_educator_expense_cap_invariants.py @@ -0,0 +1,235 @@ +"""Invariants for the educator expense deduction, 26 USC 62(a)(2)(D) and (d). + +Each eligible educator deducts their own qualified expenses up to the cap. On +a joint return the head's and the spouse's capped amounts add up; neither +spouse can use the other's unused room. A tax unit dependent's expenses are +on the dependent's own return, not the filer's. + +Hypothesis draws batches of tax units (single, joint with zero, one or two +educators, and head of household or joint with dependents who may report +educator expenses), and a seeded population of 200 units adds breadth. Each +batch runs as one vectorized simulation, and again with every member's +expenses raised by a drawn non-negative amount. For every tax unit: + +1. Bound: 0 <= educator_expense_ald <= the cap times the number of head and + spouse with expenses, and never more than their expenses. +2. Differential: educator_expense_ald equals an independent numpy sum over + the head and spouse of min(expenses, cap), with the cap taken from the + Rev. Procs. rather than from the model's parameters. Each person's own + amount, dependents included, is min(expenses, cap). +3. Monotone: raising expenses never lowers educator_expense_ald and never + raises AGI. Raising only dependents' expenses changes neither. +4. Accounting: with wages as the only income and no other deductions, AGI is + wages less educator_expense_ald. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Per-educator caps from the Rev. Procs. for each taxable year: 2020-45 +# sec. 3.14 (2021), 2021-45 sec. 3.13 (2022), 2024-40 sec. 2.13 (2025) and +# 2025-32 sec. 4.12 (2026). +PUBLISHED_CAP = {2021: 250, 2022: 300, 2025: 300, 2026: 350} +YEARS = sorted(PUBLISHED_CAP) + +# Mostly amounts near the caps, plus zeros and large amounts. +expenses = st.one_of( + st.just(0.0), + st.integers(1, 600).map(float), + st.integers(600, 5_000).map(float), +) +increases = st.one_of(st.just(0.0), st.integers(1, 500).map(float)) +wages = st.integers(0, 150_000).map(float) + + +@st.composite +def people(draw): + return { + "employment_income": draw(wages), + "educator_expense": draw(expenses), + "increase": draw(increases), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "hoh", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + } + + +SEED = 20261005 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def person(): + expense = rng.choice([0, 1, 2]) + return { + "employment_income": float(rng.integers(0, 150_001)), + "educator_expense": float( + [0, rng.integers(1, 601), rng.integers(600, 5_001)][expense] + ), + "increase": float(rng.integers(0, 501)) if rng.random() < 0.7 else 0.0, + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "hoh", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + } + ) + return units + + +def _situation(units, year, *, raise_filers, raise_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role, raised): + expense = values["educator_expense"] + raised * values["increase"] + people[name] = { + "age": {"head": 45, "spouse": 43, "dependent": 20}[role], + "is_full_time_student": role == "dependent", + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["employment_income"], + "educator_expense": expense, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head", raise_filers) + members, couple = [head], [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse", raise_filers) + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, dependent in enumerate(unit["dependents"]): + name = f"dependent_{i}_{j}" + add(name, dependent, "dependent", raise_dependents) + members.append(name) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, raise_filers=False, raise_dependents=False): + sim = Simulation( + situation=_situation( + units, + year, + raise_filers=raise_filers, + raise_dependents=raise_dependents, + ) + ) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "educator_expense", + "educator_expense_ald_person", + "employment_income", + "educator_expense_ald", + "above_the_line_deductions", + "adjusted_gross_income", + ] + } + out["filer"] = np.asarray(sim.calculate("is_tax_unit_head_or_spouse", year)) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["educator_expense_ald"]) + ) + + +def _check(units, year): + cap = PUBLISHED_CAP[year] + base = _run(units, year) + filer = base["filer"] + expense = base["educator_expense"] + + # 1. Bounds. + deduction = base["educator_expense_ald"] + educators = _unit_sum(base, (filer & (expense > 0)).astype(float)) + assert (deduction >= 0).all() + assert (deduction <= cap * educators + TOLERANCE).all() + assert (deduction <= _unit_sum(base, filer * expense) + TOLERANCE).all() + + # 2. Differential against numpy. + own = np.minimum(expense, cap) + np.testing.assert_allclose(base["educator_expense_ald_person"], own, atol=TOLERANCE) + np.testing.assert_allclose(deduction, _unit_sum(base, filer * own), atol=TOLERANCE) + np.testing.assert_allclose( + base["above_the_line_deductions"], deduction, atol=TOLERANCE + ) + + # 3. Monotone in expenses. + raised = _run(units, year, raise_filers=True, raise_dependents=True) + assert (raised["educator_expense_ald"] >= deduction - TOLERANCE).all() + assert ( + raised["adjusted_gross_income"] <= base["adjusted_gross_income"] + TOLERANCE + ).all() + dependents_only = _run(units, year, raise_dependents=True) + for name in ["educator_expense_ald", "adjusted_gross_income"]: + np.testing.assert_allclose( + dependents_only[name], base[name], atol=TOLERANCE, err_msg=name + ) + + # 4. Accounting identity. + np.testing.assert_allclose( + base["adjusted_gross_income"], + _unit_sum(base, filer * base["employment_income"]) - deduction, + atol=TOLERANCE, + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_educator_expense_cap_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml new file mode 100644 index 00000000000..c3d448c99f9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person_educator_expense.yaml @@ -0,0 +1,56 @@ +- name: Each person subtracts their own capped educator expenses. + period: 2022 + input: + people: + person1: + is_tax_unit_head: true + medicaid_irs_gross_income: 60_000 + educator_expense: 500 + person2: + is_tax_unit_spouse: true + medicaid_irs_gross_income: 40_000 + person3: + is_tax_unit_dependent: true + medicaid_irs_gross_income: 5_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + output: + # 26 USC 62(a)(2)(D): $300 per eligible educator for 2022 (Rev. Proc. + # 2021-45 sec. 3.13). The head deducts 300 of 500; the spouse has none + # to share; the dependent deducts 300 of 400 on their own return. + medicaid_adjusted_gross_income_person: [59_700, 40_000, 4_700] + absolute_error_margin: 0.01 + +- name: A tax-unit educator deduction that differs from the own amounts is shared in proportion to them. + period: 2022 + input: + people: + person1: + is_tax_unit_head: true + medicaid_irs_gross_income: 60_000 + educator_expense: 500 + person2: + is_tax_unit_spouse: true + medicaid_irs_gross_income: 40_000 + educator_expense: 200 + person3: + is_tax_unit_dependent: true + medicaid_irs_gross_income: 5_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [person1, person2, person3] + filing_status: JOINT + # Supplied directly, as an input or a reform would set it. + educator_expense_ald: 250 + output: + # Own capped amounts for 2022: head 300 of 500, spouse 200, dependent + # 300 of 400. The head's and spouse's 500 is scaled to the 250 deduction, + # so the head subtracts 150 and the spouse 100. The dependent keeps their + # own 300, which is on their own return. + educator_expense_ald_person: [300, 200, 300] + medicaid_adjusted_gross_income_person: [59_850, 39_900, 4_700] + absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml new file mode 100644 index 00000000000..a7efcc9e1bc --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.yaml @@ -0,0 +1,341 @@ +# Educator expense deduction, 26 USC 62(a)(2)(D) and (d): each eligible +# educator deducts their qualified expenses up to the cap, $250 through 2021 +# and $300 for 2022 through 2025 (Rev. Procs. 2020-45 sec. 3.14, 2021-45 +# sec. 3.13). The Form 1040 instructions for Schedule 1, line 11 (2021, p. 88; +# 2022, p. 87) give the joint return rule: "If you and your spouse are filing +# jointly and both of you were eligible educators, the maximum deduction is +# $600. However, neither spouse can deduct more than $300 of their qualified +# expenses" ($500 and $250 for 2021). + +- name: 2021, expenses below the $250 cap are deducted in full + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 200 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 200 + educator_expense_ald: 200 + above_the_line_deductions: 200 + adjusted_gross_income: 49_800 + +- name: 2021, expenses at the $250 cap are deducted in full + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 250 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 250 + educator_expense_ald: 250 + above_the_line_deductions: 250 + adjusted_gross_income: 49_750 + +- name: 2021, expenses above the $250 cap are deducted up to the cap + period: 2021 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 250 + educator_expense_ald: 250 + above_the_line_deductions: 250 + adjusted_gross_income: 49_750 + +- name: 2022, expenses below the $300 cap are deducted in full + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 299 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 299 + educator_expense_ald: 299 + above_the_line_deductions: 299 + adjusted_gross_income: 49_701 + +- name: 2022, expenses at the $300 cap are deducted in full + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 300 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 300 + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + +- name: 2022, expenses above the $300 cap are deducted up to the cap + period: 2022 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 1_000 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + educator_expense_ald_person: 300 + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 49_700 + +- name: 2025, expenses above the $300 cap are deducted up to the cap + period: 2025 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 301 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # Rev. Proc. 2024-40 sec. 2.13: $300 for 2025. + educator_expense_ald_person: 300 + educator_expense_ald: 300 + adjusted_gross_income: 49_700 + +- name: 2026, the cap rises to $350 + period: 2026 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # Rev. Proc. 2025-32 sec. 4.12: $350 for 2026. + educator_expense_ald_person: 350 + educator_expense_ald: 350 + adjusted_gross_income: 49_650 + +- name: 2030, the projected cap is still $350 + period: 2030 + input: + people: + teacher: + age: 40 + employment_income: 50_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [teacher] + households: + household: + members: [teacher] + state_code: TX + output: + # 26 USC 62(d)(3) on the model's CPI projection: the increase on the $250 + # base is $250 x the 2030 COLA (0.451) = $112.80, which rounds to the + # nearest $50 at $100. Chaining from the rounded 2026 $350 gave $400. + educator_expense_ald_person: 350 + educator_expense_ald: 350 + adjusted_gross_income: 49_650 + +- name: 2022, joint return with two eligible educators, each capped at $300 + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 450 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 250 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The head's $450 is capped at $300; the spouse's $250 is below the cap. + # The head's unused room does not carry to the spouse: 300 + 250 = 550. + educator_expense_ald_person: [300, 250] + educator_expense_ald: 550 + above_the_line_deductions: 550 + adjusted_gross_income: 89_450 + +- name: 2022, joint return with two eligible educators both above the cap reaches the $600 maximum + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 800 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 700 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [300, 300] + educator_expense_ald: 600 + adjusted_gross_income: 89_400 + +- name: 2022, joint return with one eligible educator is capped at $300, not $600 + period: 2022 + input: + people: + head: + age: 40 + employment_income: 50_000 + educator_expense: 700 + spouse: + age: 38 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [300, 0] + educator_expense_ald: 300 + above_the_line_deductions: 300 + adjusted_gross_income: 89_700 + +- name: 2021, joint return with one eligible educator is capped at $250, not $500 + period: 2021 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 38 + employment_income: 40_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + educator_expense_ald_person: [0, 250] + educator_expense_ald: 250 + adjusted_gross_income: 89_750 + +- name: 2022, a dependent's educator expenses stay off the filer's return + period: 2022 + input: + people: + parent: + age: 50 + is_tax_unit_head: true + employment_income: 60_000 + educator_expense: 100 + student: + age: 22 + is_full_time_student: true + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + employment_income: 3_000 + educator_expense: 400 + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # The student's own amount is capped for their own return; only the + # head's $100 is on this one. + educator_expense_ald_person: [100, 300] + educator_expense_ald: 100 + above_the_line_deductions: 100 + adjusted_gross_income: 59_900 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml index c9c384d913c..f5dfb884933 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.yaml @@ -54,6 +54,35 @@ # Taxable = min(0.5 * 20,000, 0.5 * (26,000 - 25,000)) = 500. dependent_taxable_social_security: [0, 500] +- name: A dependent's educator expenses reduce their modified AGI up to the cap. + period: 2022 + input: + people: + head: + age: 45 + is_tax_unit_head: true + employment_income: 50_000 + parent: + age: 70 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + social_security_retirement: 20_000 + taxable_interest_income: 16_000 + educator_expense: 500 + tax_units: + tax_unit: + members: [head, parent] + households: + household: + members: [head, parent] + output: + # Modified AGI = 16,000 - 300, the 2022 cap of 26 USC 62(a)(2)(D) + # (Rev. Proc. 2021-45 sec. 3.13) on the 500 of expenses = 15,700. + dependent_taxable_ss_magi: [0, 15_700] + # Provisional income = 15,700 + 10,000 = 25,700. + # Taxable = min(0.5 * 20,000, 0.5 * (25,700 - 25,000)) = 350. + dependent_taxable_social_security: [0, 350] + - name: A married dependent who lives with their spouse has the separate-return capital loss limit. period: 2022 input: diff --git a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py index 6da33a15d0c..9049852d5a0 100644 --- a/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py +++ b/policyengine_us/tests/policy/baseline/parameters/test_uprating_extensions.py @@ -12,8 +12,10 @@ from policyengine_us.tools.per_capita_uprating import per_capita_path from policyengine_us.parameters.uprating_extensions import ( DEPENDENT_STANDARD_DEDUCTION_STATUTORY_BASES, + EDUCATOR_EXPENSE_CAP_STATUTORY_BASE, LONG_RUN_CBO_INCOME_BY_SOURCE_PARAMETERS, extend_dependent_standard_deduction_parameters, + extend_educator_expense_cap, get_irs_cola, get_irs_cola_denominator, round_social_security_amount, @@ -670,3 +672,108 @@ def test_dependent_standard_deduction_projections_follow_statute(): assert dependent.additional_earned_income("2032-01-01") == 550 assert dependent.additional_earned_income("2042-01-01") == 650 assert dependent.amount("2042-01-01") == 1_900 + + +def statutory_educator_expense_cap(year): + """26 U.S.C. 62(d)(3): $250 plus its increase, to the nearest $50.""" + base, base_year = EDUCATOR_EXPENSE_CAP_STATUTORY_BASE + increase = base * get_irs_cola(PARAMETERS, year, base_year) + lower = math.floor(increase / 50) * 50 + return base + (lower if increase - lower < 25 else lower + 50) + + +def encoded_educator_expense_caps(): + """The year: value pairs written in the educator expense cap YAML.""" + path = ( + Path(__file__).parents[4] / "parameters/gov/irs/ald/educator_expense/cap.yaml" + ) + values = yaml.safe_load(path.read_text())["values"] + return {int(str(date)[:4]): value for date, value in values.items()} + + +def test_irs_cola_reproduces_published_educator_expense_caps(): + """The 62(d)(3) computation matches every IRS-published cap, 2016-2026.""" + encoded = encoded_educator_expense_caps() + for year in range(2016, 2027): + assert statutory_educator_expense_cap(year) == encoded[year], year + + +def test_educator_expense_cap_encoded_values_take_precedence(): + """The published caps in the YAML survive the statutory extension.""" + cap = PARAMETERS.gov.irs.ald.educator_expense.cap + for year, value in encoded_educator_expense_caps().items(): + assert cap(f"{year}-01-01") == value, year + # The 2002 value covers 2002-2015, before 62(d)(3) indexing. + for year in range(2002, 2016): + assert cap(f"{year}-01-01") == 250, year + + +def test_educator_expense_cap_projections_follow_statute(): + """Projected caps come from the $250 base, not the rounded 2026 $350.""" + cap = PARAMETERS.gov.irs.ald.educator_expense.cap + last_encoded_year = max(encoded_educator_expense_caps()) + previous = cap(f"{last_encoded_year}-01-01") + for year in range(last_encoded_year + 1, 2101): + value = cap(f"{year}-01-01") + assert value == statutory_educator_expense_cap(year), year + assert value % 50 == 0, year + assert value >= previous, year + previous = value + assert cap("2150-01-01") == cap("2100-01-01") + + # Hand anchors. They rest on the model's CPI projection, so a CBO refresh + # can move them; 2031 and 2032 sit within $5 of the $125 step. The 2014 + # denominator is 134.30. The increase, $250 x the COLA, rounds to $100 + # while under $125: $89.57 in 2027, $112.80 in 2030 and $120.06 in 2031. + # It rounds to $150 from $127.32 in 2032 to $150.03 in 2035. Chaining + # from the rounded 2026 $350 reached $400 in 2030. + for year in range(2027, 2032): + assert cap(f"{year}-01-01") == 350, year + for year in range(2032, 2036): + assert cap(f"{year}-01-01") == 400, year + + +def test_educator_expense_cap_extension_rounds_the_base_increase_to_nearest_50(): + """Encoded years are kept; later years round the $250 base's increase.""" + + def months(start_year, level): + return { + **{f"{start_year}-{month:02d}-01": level for month in range(9, 13)}, + **{f"{start_year + 1}-{month:02d}-01": level for month in range(1, 9)}, + } + + cpi = SimpleNamespace( + cpi_u=Parameter("cpi_u", data={**months(2013, 100), **months(2015, 200)}), + # Monthly observations end in August 2030; 2032 holds a projection. + c_cpi_u=Parameter( + "c_cpi_u", + data={**months(2015, 150), **months(2029, 102), "2032-02-01": 126}, + ), + ) + # A node, so Parameter.update can reach a parent. + educator_expense = ParameterNode( + "educator_expense", + data={"cap": {"values": {"2002-01-01": 250, "2030-01-01": 999}}}, + ) + parameters = SimpleNamespace( + gov=SimpleNamespace( + bls=SimpleNamespace(cpi=cpi), + irs=SimpleNamespace(ald=SimpleNamespace(educator_expense=educator_expense)), + ) + ) + extend_educator_expense_cap(parameters, 2033) + + cap = educator_expense.cap + # Encoded years keep their values, statutory or not. + assert cap("2029-01-01") == 250 + assert cap("2030-01-01") == 999 + # Denominator: 2014 base 100 x 150 / 200 = 75. 2031 reads the observed + # Sep 2029-Aug 2030 window (102); 2032 has no observed month and no + # projection point of its own, so it reads 102 too. + # $250 x (102 / 75 - 1) = $90, nearest to $100 (rounding down gives $50). + for year in (2031, 2032): + assert cap(f"{year}-01-01") == 350, year + # 2033 reads the 2032 projection point (126). + # $250 x (126 / 75 - 1) = $170, nearest to $150 (rounding up gives $200). + assert cap("2033-01-01") == 400 + assert cap("2040-01-01") == 400 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index b1176492612..4e10854fd09 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -7,6 +7,9 @@ "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", "alimony_expense_ald": "alimony_expense_ald_person", + # Each educator's own capped expenses, a tax unit dependent's included, + # since the dependent deducts them on their own return. + "educator_expense_ald": "educator_expense_ald_person", } diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 8ee985038a9..90be8e0597e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -4,6 +4,7 @@ Above-the-line deductions are deductions made from gross income to arrive at adj | Section | Deduction | | --- | --- | +| [62(a)(2)(D)](https://www.law.cornell.edu/uscode/text/26/62#a_2_D) | Educator expenses, capped per eligible educator | | [85](https://www.law.cornell.edu/uscode/text/26/85) | Unemployment compensation | | [86](https://www.law.cornell.edu/uscode/text/26/86) | Social security | | [135](https://www.law.cornell.edu/uscode/text/26/135) | Tuition fees | diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py new file mode 100644 index 00000000000..865ae60ca8a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class educator_expense_ald(Variable): + value_type = float + entity = TaxUnit + label = "Educator expense deduction" + unit = USD + documentation = ( + "Above-the-line deduction for the head's and spouse's educator " + "expenses (Schedule 1, line 11). Each eligible educator deducts up to " + "the cap; on a joint return neither spouse can use the other's unused " + "amount. A tax unit dependent deducts their own expenses on their own " + "return, as irs_gross_income leaves their income off this one." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d", + # Form 1040 instructions (2025), Schedule 1, line 11. + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=93", + ) + + def formula(tax_unit, period, parameters): + return tax_unit_non_dep_sum("educator_expense_ald_person", tax_unit, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py new file mode 100644 index 00000000000..6f57a28f1fe --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/educator_expense_ald_person.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class educator_expense_ald_person(Variable): + value_type = float + entity = Person + label = "Educator expense deduction for each person" + unit = USD + documentation = ( + "Each person's educator expenses, capped at the per-educator limit of " + "26 USC 62(a)(2)(D). This is the amount for the person's own return; " + "educator_expense_ald adds the head's and spouse's amounts for the " + "tax unit's return. Any positive educator_expense is taken to be an " + "eligible educator's: kindergarten through grade 12 in every year, " + "and early childhood from 2026. Both groups have the same cap." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d", + ) + + def formula(person, period, parameters): + expenses = max_(0, person("educator_expense", period)) + cap = parameters(period).gov.irs.ald.educator_expense.cap + return min_(expenses, cap) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py index 423ff12bf0e..e3fd6292b91 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/dependent_taxable_ss_magi.py @@ -19,7 +19,7 @@ "self_employment_tax_ald": "self_employment_tax_ald_person", "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", - "educator_expense": "educator_expense", + "educator_expense_ald": "educator_expense_ald_person", "early_withdrawal_penalty": "early_withdrawal_penalty", "traditional_ira_contributions": "traditional_ira_contributions", "alimony_expense_ald": "alimony_expense_ald_person", diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py index 2d5ac005513..b2ad7b79e3e 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/oh_educator_expense_deduction_person.py @@ -10,8 +10,9 @@ class oh_educator_expense_deduction_person(Variable): default_value = 0 defined_for = StateCode.OH # ORC § 5747.01(A)(31): Ohio allows a deduction ONLY for educator - # expenses in EXCESS of the federal IRC § 62(a)(2)(D) cap (currently - # $300). The federal-cap portion is already deducted via federal AGI; + # expenses in EXCESS of the federal IRC § 62(a)(2)(D) cap + # (gov.irs.ald.educator_expense.cap). The federal-cap portion is already + # deducted via federal AGI (educator_expense_ald); # adding the federal `educator_expense` here would double-count. This # stub variable therefore replaces (not supplements) the federal # educator deduction in oh/.../deductions.yaml. It remains an explicit diff --git a/policyengine_us/variables/household/expense/person/educator_expense.py b/policyengine_us/variables/household/expense/person/educator_expense.py index 68edbcb7bcd..7e6a582eba2 100644 --- a/policyengine_us/variables/household/expense/person/educator_expense.py +++ b/policyengine_us/variables/household/expense/person/educator_expense.py @@ -6,5 +6,24 @@ class educator_expense(Variable): entity = Person label = "Educator expenses" unit = USD - documentation = "Expenses necessary for carrying out educator-related duties." + documentation = ( + "Unreimbursed qualified expenses an eligible educator paid or " + "incurred. An eligible educator is a teacher, instructor, counselor, " + "principal or aide in a school for at least 900 hours in the school " + "year. Kindergarten through grade 12 educators qualify in every year. " + "Early childhood educators qualify for tax years beginning after " + "2025 (Pub. L. 119-111, division B, section 1), in a school or " + "childcare facility that serves more than two non-resident children " + "under age 6 and is publicly funded or paid for those services. The " + "model has no input for educator status and treats any positive " + "amount as an eligible educator's, so enter an early childhood " + "educator's expenses only for 2026 and later. Enter the full amount; " + "the deduction applies the per-educator cap." + ) definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62#a_2_D", + "https://www.law.cornell.edu/uscode/text/26/62#d_1", + # Pub. L. 119-111, division B, section 1 (140 Stat. 1049). + "https://www.govinfo.gov/content/pkg/PLAW-119publ111/html/PLAW-119publ111.htm", + )