diff --git a/changelog.d/elderly-credit-separate-filer-base.fixed.md b/changelog.d/elderly-credit-separate-filer-base.fixed.md new file mode 100644 index 00000000000..5c5745be5f6 --- /dev/null +++ b/changelog.d/elderly-credit-separate-filer-base.fixed.md @@ -0,0 +1 @@ +Give a married individual filing separately the $3,750 Schedule R initial amount, and set the section 22 amount to zero for a separate filer who lived with their spouse at any time in the year, so the elderly or disabled credit and Michigan's section 22 subtraction both follow 26 U.S.C. 22(c)(2)(A) and 22(e)(1). diff --git a/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml b/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml index c9a99f259cd..be4d8fb69a9 100644 --- a/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml +++ b/policyengine_us/parameters/gov/irs/credits/elderly_or_disabled/phase_out.yaml @@ -23,13 +23,13 @@ threshold: - filing_status propagate_metadata_to_children: true reference: - - title: 26 U.S. Code § 22(c)(3)(D) - href: https://www.law.cornell.edu/uscode/text/26/22#c_3_D + - title: 26 U.S. Code § 22(d) + href: https://www.law.cornell.edu/uscode/text/26/22#d rate: values: 2010-01-01: 0.5 metadata: unit: /1 reference: - - title: 26 U.S. Code § 22(c)(3)(D) - href: https://www.law.cornell.edu/uscode/text/26/22#c_3_D + - title: 26 U.S. Code § 22(d) + href: https://www.law.cornell.edu/uscode/text/26/22#d diff --git a/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py new file mode 100644 index 00000000000..6bbf0f26bff --- /dev/null +++ b/policyengine_us/tests/core/test_elderly_disabled_credit_invariants.py @@ -0,0 +1,209 @@ +"""Properties of the elderly or disabled credit (26 U.S.C. 22, Schedule R). + +1. A married individual filing a separate return starts from $3,750 + (22(c)(2)(A)(iii)), and the later steps only reduce it, so the section 22 + amount for a separate filer never exceeds $3,750. +2. For every status but separate, the section 22 amount equals the formula + before the separate-filer change, frozen below as a reference function. +3. For a separate filer it equals the closed form of 22(c)-(e) with the + model's count of qualifying members (which, like the model, includes + dependents): zero if the spouses lived together at any time in the + year (22(e)(1)); otherwise min($3,750 if any member qualifies, the + 22(c)(2)(B) cap), less nontaxable pensions and social security + (22(c)(3)), less half of AGI over $5,000 (22(d)), floored at zero. + +Units may carry a dependent, who can qualify by age, so a separate filer +can count two qualified individuals. + +Each Hypothesis example is a batch of tax units run as one vectorized +simulation, so the test checks the formulas on mixed arrays as well. +""" + +from hypothesis import HealthCheck, example, given, settings, strategies as st +import numpy as np + +from policyengine_us import Simulation + +YEAR = 2024 +SEPARATE_INITIAL_AMOUNT = 3_750 +ONE_QUALIFIED = 5_000 +TWO_QUALIFIED = 7_500 +ELDERLY_AGE = 65 +PHASE_OUT_RATE = 0.5 +PHASE_OUT_THRESHOLD = { + "SINGLE": 7_500, + "JOINT": 10_000, + "SEPARATE": 5_000, + "HEAD_OF_HOUSEHOLD": 7_500, +} + +FILING_STATUSES = ["SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD"] + +money = st.one_of(st.just(0.0), st.floats(0, 60_000).map(round)) + +case = st.fixed_dictionaries( + { + "filing_status": st.sampled_from(FILING_STATUSES), + "cohabitating": st.booleans(), + "head_age": st.integers(40, 90), + "spouse_age": st.integers(40, 90), + "head_disabled": st.booleans(), + "wages": money, + "pension": money, + "taxable_share": st.floats(0, 1), + "social_security": money, + "disability_payments": st.floats(0, 10_000).map(round), + # Two in three units have a dependent, half of them 65 or older, so + # separate filers with two qualified members come up often. + "dependent_age": st.one_of(st.none(), st.integers(65, 90), st.integers(10, 64)), + } +) + + +def build_situation(cases): + people, tax_units, households = {}, {}, {} + for i, c in enumerate(cases): + head = f"head_{i}" + pension = c["pension"] + people[head] = { + "age": {YEAR: c["head_age"]}, + "retired_on_total_disability": {YEAR: c["head_disabled"]}, + "employment_income": {YEAR: c["wages"]}, + "total_disability_payments": { + YEAR: min(c["disability_payments"], c["wages"]) + }, + "pension_income": {YEAR: pension}, + "taxable_pension_income": {YEAR: round(pension * c["taxable_share"])}, + "social_security_retirement": {YEAR: c["social_security"]}, + } + members = [head] + if c["filing_status"] == "JOINT": + spouse = f"spouse_{i}" + people[spouse] = {"age": {YEAR: c["spouse_age"]}} + members.append(spouse) + if c["dependent_age"] is not None: + dependent = f"dependent_{i}" + people[dependent] = { + "age": {YEAR: c["dependent_age"]}, + "is_tax_unit_dependent": {YEAR: True}, + } + members.append(dependent) + tax_units[f"tax_unit_{i}"] = { + "members": members, + "filing_status": {YEAR: c["filing_status"]}, + "cohabitating_spouses": {YEAR: c["cohabitating"]}, + } + households[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + return {"people": people, "tax_units": tax_units, "households": households} + + +def reduction_and_cap_inputs(sim): + """Per-unit inputs shared by both reference formulas, read from the + simulation so that only the section 22 step itself is reimplemented.""" + age = sim.calculate("age", YEAR) + disabled = sim.calculate("retired_on_total_disability", YEAR) + dependent = sim.calculate("is_tax_unit_dependent", YEAR) + disability_pay = sim.calculate("total_disability_payments", YEAR) + elderly = age >= ELDERLY_AGE + n = sim.map_result(elderly | disabled, "person", "tax_unit") + num_elderly = sim.map_result(elderly & ~dependent, "person", "tax_unit") + young_disability = sim.map_result(disability_pay * ~elderly, "person", "tax_unit") + cap = num_elderly * ONE_QUALIFIED + young_disability + pensions = sim.map_result( + sim.calculate("pension_income", YEAR) + - sim.calculate("taxable_pension_income", YEAR), + "person", + "tax_unit", + ) + social_security = sim.calculate("tax_unit_social_security", YEAR) - sim.calculate( + "tax_unit_taxable_social_security", YEAR + ) + agi = sim.calculate("adjusted_gross_income", YEAR) + return n, cap, pensions + social_security, agi + + +def pre_change_section_22(n, filing_status, cap, nontaxable, agi): + """The section 22 formula before the separate-filer change: select took + n == 1 before the separate-return condition.""" + initial = np.select( + [n == 1, n == 2, filing_status == "SEPARATE"], + [ONE_QUALIFIED, TWO_QUALIFIED, SEPARATE_INITIAL_AMOUNT], + default=0, + ) + threshold = np.array([PHASE_OUT_THRESHOLD[f] for f in filing_status]) + reduction = PHASE_OUT_RATE * np.maximum(0, agi - threshold) + return np.maximum(0, np.minimum(initial, cap) - nontaxable - reduction) + + +def statute_separate_section_22(n, lived_together, cap, nontaxable, agi): + """26 U.S.C. 22(c)-(e) for a married individual filing separately, counting + qualifying members as the model does (dependents included).""" + initial = np.where(n >= 1, SEPARATE_INITIAL_AMOUNT, 0) + reduction = PHASE_OUT_RATE * np.maximum(0, agi - PHASE_OUT_THRESHOLD["SEPARATE"]) + amount = np.maximum(0, np.minimum(initial, cap) - nontaxable - reduction) + return np.where(lived_together, 0, amount) + + +def fixed_case(filing_status, cohabitating, head_age=67, dependent_age=None, **kw): + c = { + "filing_status": filing_status, + "cohabitating": cohabitating, + "head_age": head_age, + "spouse_age": 50, + "head_disabled": False, + "wages": 3_000.0, + "pension": 0.0, + "taxable_share": 0.0, + "social_security": 0.0, + "disability_payments": 0.0, + "dependent_age": dependent_age, + } + c.update(kw) + return c + + +# A batch that always runs, beside the random ones, so each separate-filer +# branch is reached whatever Hypothesis draws. +FIXED_BATCH = [ + # Separate, lived apart, two qualified members (head and dependent). + fixed_case("SEPARATE", False, dependent_age=80), + # Separate, lived together: 22(e)(1) bars the amount. + fixed_case("SEPARATE", True), + # Separate, lived together, two qualified members. + fixed_case("SEPARATE", True, dependent_age=80), + # Separate, nobody qualifies, with disability pay. + fixed_case("SEPARATE", False, head_age=60, disability_payments=2_000.0), + # Joint and single returns whose spouses live together keep the amount. + fixed_case("JOINT", True), + fixed_case("SINGLE", True), + fixed_case("HEAD_OF_HOUSEHOLD", True, dependent_age=70), + fixed_case("JOINT", False, spouse_age=70, dependent_age=80), +] + + +@settings( + max_examples=10, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(case, min_size=8, max_size=8)) +@example(FIXED_BATCH) +def test_section_22_amount_against_reference_formulas(cases): + sim = Simulation(situation=build_situation(cases)) + filing_status = sim.calculate("filing_status", YEAR).decode_to_str() + lived_together = sim.calculate("cohabitating_spouses", YEAR) + section_22 = sim.calculate("section_22_income", YEAR) + n, cap, nontaxable, agi = reduction_and_cap_inputs(sim) + + separate = filing_status == "SEPARATE" + assert (section_22[separate] <= SEPARATE_INITIAL_AMOUNT).all() + assert (section_22 >= 0).all() + + pre_change = pre_change_section_22(n, filing_status, cap, nontaxable, agi) + np.testing.assert_allclose(section_22[~separate], pre_change[~separate]) + + statute = statute_separate_section_22(n, lived_together, cap, nontaxable, agi) + np.testing.assert_allclose(section_22[separate], statute[separate]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml new file mode 100644 index 00000000000..0bb6c6ba595 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/elderly_and_disabled/section_22_income.yaml @@ -0,0 +1,243 @@ +# Section 22 amount (Schedule R line 19) by filing status, 26 U.S.C. 22(c) +# and (d). Every case has wages only (no pensions or social security) unless +# noted, so AGI equals wages. + +- name: A separate filer aged 67 who lived apart all year starts from $3,750. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(A)(iii): $3,750. AGI $3,000 is under the $5,000 separate + # threshold, so no phase-out: 3,750. + section_22_income: 3_750 + # 22(a): 15% x 3,750 = 562.50. + elderly_disabled_credit_potential: 562.5 + +- name: A separate filer who lived with their spouse has no section 22 amount and no credit. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [head] + state_code: TX + output: + # 22(e)(1): married spouses who did not live apart all year must file + # jointly to take the credit. Schedule R boxes 8 and 9, the only boxes + # for a separate filer, require living apart all year, so there is no + # line 19 amount. + section_22_income: 0 + elderly_disabled_credit_potential: 0 + elderly_disabled_credit: 0 + +- name: A separate filer's $3,750 is phased out and reduced by nontaxable social security. + period: 2024 + input: + people: + head: + age: 70 + employment_income: 6_000 + social_security_retirement: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # Provisional income 6,000 + 500 = 6,500 is under the $25,000 base for + # a separate filer who lived apart, so all $1,000 of benefits is + # nontaxable and AGI is 6,000. + tax_unit_taxable_social_security: 0 + adjusted_gross_income: 6_000 + # 3,750 - 1,000 nontaxable benefits - 0.5 x (6,000 - 5,000) = 2,250. + section_22_income: 2_250 + # 15% x 2,250 = 337.50. + elderly_disabled_credit_potential: 337.5 + +- name: A separate filer under 65 is capped at disability income. + period: 2024 + input: + people: + head: + age: 60 + retired_on_total_disability: true + employment_income: 2_000 + total_disability_payments: 2_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(B): min(3,750, 2,000 disability income) = 2,000; AGI 2,000 + # is under the threshold. + section_22_income: 2_000 + elderly_disabled_credit_potential: 300 + +- name: A single filer aged 67 starts from $5,000. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: TX + output: + # 22(c)(2)(A)(i): $5,000; AGI 3,000 < 7,500. + section_22_income: 5_000 + elderly_disabled_credit_potential: 750 + +- name: A joint return with one qualified spouse starts from $5,000. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(c)(2)(A)(i): $5,000; AGI 3,000 < 10,000. + section_22_income: 5_000 + elderly_disabled_credit_potential: 750 + +- name: A joint return with two qualified spouses starts from $7,500. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 66 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(c)(2)(A)(ii): $7,500; AGI 3,000 < 10,000. + section_22_income: 7_500 + # 15% x 7,500 = 1,125. + elderly_disabled_credit_potential: 1_125 + +- name: A separate filer with a qualified dependent still starts from $3,750. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + dependent: + age: 80 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head, dependent] + state_code: TX + output: + # Two members qualify (both are 65 or older), but 22(c)(2)(A)(iii) + # gives a separate filer $3,750 whatever the count. The under-65 cap + # counts only the elderly head (dependents excluded): 1 x 5,000, above + # 3,750. AGI 3,000 < 5,000: 3,750. + section_22_income: 3_750 + elderly_disabled_credit_potential: 562.5 + +- name: Living together does not bar a joint return. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + spouse: + age: 50 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + cohabitating_spouses: true + households: + household: + members: [head, spouse] + state_code: TX + output: + # 22(e)(1) reaches only separate returns. 22(c)(2)(A)(i): $5,000 with + # one qualified spouse; AGI 3,000 < 10,000. + section_22_income: 5_000 + # 15% x 5,000 = 750. + elderly_disabled_credit_potential: 750 + +- name: A separate filer with no qualifying member has no initial amount. + period: 2024 + input: + people: + head: + age: 60 + employment_income: 2_000 + total_disability_payments: 2_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: false + households: + household: + members: [head] + state_code: TX + output: + # Under 65 and not retired on total disability, so 22(b) doesn't reach + # this filer: no initial amount, whatever the disability pay. + section_22_income: 0 + elderly_disabled_credit_potential: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml index 046978ff58e..8ae2f31b6ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_subtractions.yaml @@ -170,3 +170,32 @@ mi_subtractions: 49_422.75 # 110,000 - 49,422.75 - 11,600 mi_taxable_income: 48_977.25 + +- name: A separate filer who lived with their spouse has no section 22 amount to subtract. + period: 2024 + input: + people: + head: + age: 67 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SEPARATE + cohabitating_spouses: true + households: + household: + members: [head] + state_code: MI + output: + # MCL 206.30(1)(f)(v) subtracts the section 22 amount eligible for the + # federal credit, which the MI-1040 instructions take from Schedule R + # line 19. A separate filer who lived with their spouse can't check + # Schedule R box 8 or 9 and has no line 19 amount (26 U.S.C. 22(e)(1)). + section_22_income: 0 + # Born 1957, so the tier three standard deduction: 20,000 base for a + # separate filer less one 5,600 personal exemption = 14,400. No other + # subtraction applies to $3,000 of wages. + mi_standard_deduction: 14_400 + # 0 + 14,400 = 14,400. + mi_subtractions: 14_400 diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py index 8fc9f1143b2..fbaac9aab66 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/elderly_disabled_credit_potential.py @@ -8,8 +8,9 @@ class elderly_disabled_credit_potential(Variable): label = "Potential value of the Elderly or disabled credit" documentation = "Schedule R credit for the elderly and the disabled" unit = USD - reference = "https://www.law.cornell.edu/uscode/text/26/22" + reference = "https://www.law.cornell.edu/uscode/text/26/22#a" def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.credits.elderly_or_disabled + # The 22(e)(1) separate-return bar is applied in section_22_income. return p.rate * tax_unit("section_22_income", period) diff --git a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py index 07760c79d44..7f37ba286b1 100644 --- a/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py +++ b/policyengine_us/variables/gov/irs/credits/elderly_and_disabled/section_22_income.py @@ -8,7 +8,10 @@ class section_22_income(Variable): unit = USD documentation = "Income upon which the elderly or disabled credit is applied" definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/22" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/22#c", + "https://www.law.cornell.edu/uscode/text/26/22#e_1", + ) def formula(tax_unit, period, parameters): elderly_disabled = parameters(period).gov.irs.credits.elderly_or_disabled @@ -18,16 +21,23 @@ def formula(tax_unit, period, parameters): num_qualifying_individuals = tax_unit.sum( person("qualifies_for_elderly_or_disabled_credit", period) ) + separate = filing_status == filing_status.possible_values.SEPARATE + # Section 22(c)(2)(A)(iii) sets a separate filer's initial amount, + # so the separate-return condition comes first: select takes the + # first true condition. It tests n >= 1, not n == 1, because + # qualifies_for_elderly_or_disabled_credit is not limited to the + # head and spouse: a separate filer with a qualifying dependent + # counts two, and would otherwise fall through to $7,500. initial_amount = select( [ + separate & (num_qualifying_individuals >= 1), num_qualifying_individuals == 1, num_qualifying_individuals == 2, - filing_status == filing_status.possible_values.SEPARATE, ], [ + elderly_disabled.amount.separate, elderly_disabled.amount.one_qualified, elderly_disabled.amount.two_qualified, - elderly_disabled.amount.separate, ], default=0, ) @@ -62,4 +72,14 @@ def formula(tax_unit, period, parameters): ) phase_out_reduction = elderly_disabled.phase_out.rate * amount_over_phase_out - return max_(0, capped_reduced_amount - phase_out_reduction) + amount = max_(0, capped_reduced_amount - phase_out_reduction) + # Section 22(e)(1): a married individual filing a separate return + # takes the credit only if the spouses lived apart for the whole + # year. Schedule R gives a separate filer only boxes 8 and 9, both + # conditioned on "you lived apart from your spouse for all of" the + # year, so a separate filer who lived with the spouse has no line 19 + # section 22 amount. The bar sits here, not on the credit, so that + # readers of the line 19 amount, such as Michigan's subtraction, + # see it too. + lived_together = tax_unit("cohabitating_spouses", period) + return where(separate & lived_together, 0, amount)