diff --git a/changelog.d/military-retirement-state-followups.fixed.md b/changelog.d/military-retirement-state-followups.fixed.md new file mode 100644 index 00000000000..42f9b24d26d --- /dev/null +++ b/changelog.d/military-retirement-state-followups.fixed.md @@ -0,0 +1 @@ +Apply Rhode Island's retirement income modification age test to each spouse, and add an input that marks military retirement pay as armed forces pay, for states whose exclusions cover the armed forces only. South Carolina counts NOAA and PHS retired pay toward its general retirement deduction, and Indiana's military deduction covers all uniformed services from 2025. diff --git a/policyengine_us/parameters/gov/states/in/tax/income/deductions/military_retirement/uniformed_services_eligible.yaml b/policyengine_us/parameters/gov/states/in/tax/income/deductions/military_retirement/uniformed_services_eligible.yaml new file mode 100644 index 00000000000..dd68ca5225b --- /dev/null +++ b/policyengine_us/parameters/gov/states/in/tax/income/deductions/military_retirement/uniformed_services_eligible.yaml @@ -0,0 +1,15 @@ +description: Indiana extends the military retirement deduction to retirement pay and survivor benefits from all uniformed services, not only the armed forces, if this is true. +values: + 2021-01-01: false + 2025-01-01: true +metadata: + unit: bool + period: year + label: Indiana military retirement deduction covers all uniformed services + reference: + - title: "Indiana Department of Revenue, 2025 Legislative Synopsis, HEA 1280, Sec. 2 (IC 6-3-2-4) #page 103" + href: https://www.in.gov/dor/files/legislative-synopsis-2025.pdf#page=103 + - title: "Indiana Department of Revenue, Information Bulletin 27 #page 4" + href: https://www.in.gov/dor/files/ib27.pdf#page=4 + - title: IC 6-3-2-4 + href: http://iga.in.gov/legislative/laws/2021/ic/titles/006#6-3-2-4 diff --git a/policyengine_us/tests/code_health/test_military_retirement_readers.py b/policyengine_us/tests/code_health/test_military_retirement_readers.py index d341088ddc2..cbc7126fad6 100644 --- a/policyengine_us/tests/code_health/test_military_retirement_readers.py +++ b/policyengine_us/tests/code_health/test_military_retirement_readers.py @@ -44,6 +44,10 @@ "variables/household/income/person/retirement/military_retirement_income.py": ( "military_retirement_income adds the retiree pay and survivor annuities" ), + # The armed forces part, which state tax rules read. + "variables/household/income/person/retirement/armed_forces_retirement_pay.py": ( + "armed_forces_retirement_pay keeps the retiree's own armed forces pay" + ), } diff --git a/policyengine_us/tests/core/test_military_retirement_armed_forces_invariants.py b/policyengine_us/tests/core/test_military_retirement_armed_forces_invariants.py new file mode 100644 index 00000000000..d26898f3798 --- /dev/null +++ b/policyengine_us/tests/core/test_military_retirement_armed_forces_invariants.py @@ -0,0 +1,301 @@ +"""Armed forces pay reaches state rules through one input, and Rhode Island tests age per person. + +Each year uses one vectorized simulation. Every drawn tax unit appears in five +states, once with the default input and once with its pay marked as NOAA or +Public Health Service pay. The copies share every other input. Ages span the +state age tests, amounts span the deduction caps, and some units hold a dependent +with pay of their own. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + + +SEED = 20261105 +TOLERANCE = 0.05 # dollars; model calculations use float32 arrays +YEARS = [2022, 2024, 2025, 2026] +STATES = ["IN", "ND", "NM", "RI", "SC"] +FILER_AGES = [40, 55, 60, 64, 65, 66, 67, 70, 75, 85] +UNIT_COUNT = 24 +GROUPS = ("people", "tax_units", "marital_units", "households", "families", "spm_units") + + +def _draw_units(year): + rng = np.random.default_rng(SEED + year) + + def filer(i, age, surviving=False): + return { + "age": age, + "taxable_private_pension_income": ( + 0.0 if i % 5 == 0 else float(rng.integers(1, 40_001)) + ), + "military_retirement_pay": ( + 0.0 if i % 7 == 0 else float(rng.integers(1, 40_001)) + ), + "military_retirement_pay_survivors": ( + float(rng.integers(1, 30_001)) if surviving else 0.0 + ), + "is_surviving_spouse": surviving, + "deceased_spouse_age": 70 if surviving else 0, + } + + units = [] + for i in range(UNIT_COUNT): + joint = i % 2 == 1 + units.append( + { + "head": filer( + i, + FILER_AGES[i % len(FILER_AGES)], + surviving=not joint and i % 4 == 0, + ), + "spouse": ( + filer(i, FILER_AGES[(i + 4) % len(FILER_AGES)]) if joint else None + ), + "dependent": ( + { + "age": 70, + "taxable_private_pension_income": 0.0, + "military_retirement_pay": float(rng.integers(1, 20_001)), + "military_retirement_pay_survivors": 0.0, + "is_surviving_spouse": False, + "deceased_spouse_age": 0, + } + if i % 6 == 2 + else None + ), + } + ) + return units + + +def _situation(units, year): + groups = {name: {} for name in GROUPS} + people, tax_units = [], [] + + def add_person(role, key, record, state, noaa, unit_row): + name = f"{role}_{key}" + person = {variable: {year: value} for variable, value in record.items()} + person.update( + { + "is_tax_unit_head": {year: role == "head"}, + "is_tax_unit_spouse": {year: role == "spouse"}, + "is_tax_unit_dependent": {year: role == "dependent"}, + } + ) + if noaa: + person["military_retirement_pay_is_armed_forces"] = {year: False} + groups["people"][name] = person + people.append( + { + "state": state, + "noaa": noaa, + "filer": role != "dependent", + "unit": unit_row, + "age": record["age"], + "pension": record["taxable_private_pension_income"], + "pay": record["military_retirement_pay"], + "survivors": record["military_retirement_pay_survivors"], + "surviving": record["is_surviving_spouse"], + "deceased_age": record["deceased_spouse_age"], + } + ) + return name + + for state in STATES: + for noaa in (False, True): + for i, unit in enumerate(units): + key = f"{state}_{int(noaa)}_{i}" + unit_row = len(tax_units) + tax_units.append({"state": state, "noaa": noaa}) + couple = [add_person("head", key, unit["head"], state, noaa, unit_row)] + if unit["spouse"] is not None: + couple.append( + add_person("spouse", key, unit["spouse"], state, noaa, unit_row) + ) + members = list(couple) + groups["marital_units"][f"couple_{key}"] = {"members": couple} + if unit["dependent"] is not None: + name = add_person( + "dependent", key, unit["dependent"], state, noaa, unit_row + ) + members.append(name) + groups["marital_units"][f"dependent_{key}"] = {"members": [name]} + groups["tax_units"][f"tax_unit_{key}"] = {"members": members} + groups["households"][f"household_{key}"] = { + "members": members, + "state_name": {year: state}, + } + groups["families"][f"family_{key}"] = {"members": members} + groups["spm_units"][f"spm_unit_{key}"] = {"members": members} + + def columns(rows): + return {name: np.array([row[name] for row in rows]) for name in rows[0]} + + return groups, columns(people), columns(tax_units) + + +class Run: + def __init__(self, year): + self.year = year + situation, self.people, self.tax_units = _situation(_draw_units(year), year) + self.sim = Simulation(situation=situation) + self.states = self.sim.tax_benefit_system.parameters(year).gov.states + + def calc(self, variable): + return np.asarray(self.sim.calculate(variable, self.year), dtype=float) + + def by_unit(self, amounts): + return np.bincount( + self.people["unit"], weights=amounts, minlength=len(self.tax_units["noaa"]) + ) + + +@pytest.fixture(scope="module", params=YEARS) +def run(request): + return Run(request.param) + + +def _assert_close(actual, expected, message): + np.testing.assert_allclose( + actual, expected, rtol=0, atol=TOLERANCE, err_msg=message + ) + + +def test_the_default_input_counts_all_pay_as_armed_forces_pay(run): + people = run.people + armed = ~people["noaa"] + _assert_close( + run.calc("armed_forces_retirement_pay"), + people["pay"] * armed, + "armed_forces_retirement_pay", + ) + _assert_close( + run.calc("armed_forces_retirement_pay_survivors"), + people["survivors"] * armed, + "armed_forces_retirement_pay_survivors", + ) + + +@pytest.mark.parametrize( + "state, variable, start", + [ + ("ND", "nd_military_retirement_subtraction", 2019), + ("RI", "ri_military_retirement_subtraction", 2023), + ], +) +def test_military_subtractions_read_armed_forces_pay_only(run, state, variable, start): + people = run.people + rows = people["state"] == state + amount = people["pay"] + people["survivors"] * people["surviving"] + expected = amount * ~people["noaa"] * (run.year >= start) + _assert_close(run.calc(variable)[rows], expected[rows], variable) + + +def test_new_mexico_exempts_armed_forces_pay_only(run): + people = run.people + p = run.states.nm.tax.income.exemptions.armed_forces_retirement_pay + rows = people["state"] == "NM" + amount = people["pay"] + people["survivors"] * people["surviving"] * ( + p.survivors_eligible + ) + expected = np.minimum(amount, p.cap) * people["filer"] * ~people["noaa"] + _assert_close( + run.calc("nm_armed_forces_retirement_pay_exemption_person")[rows], + expected[rows], + "nm_armed_forces_retirement_pay_exemption_person", + ) + + +def test_indiana_covers_all_uniformed_services_from_2025(run): + units = run.tax_units + deduction = run.calc("in_military_retirement_deduction") + indiana = units["state"] == "IN" + armed = deduction[indiana & ~units["noaa"]] + noaa = deduction[indiana & units["noaa"]] + assert (armed > 0).any() + _assert_close(noaa, armed if run.year >= 2025 else 0 * armed, "Indiana") + + +def test_south_carolina_takes_each_dollar_of_pay_on_one_line(run): + people = run.people + p = run.states.sc.tax.income.subtractions.retirement + rows = people["state"] == "SC" + armed = ~people["noaa"] + pay, pension = people["pay"], people["pension"] + cap = p.cap.calc(people["age"]) + + military = run.calc("sc_military_deduction_indv") + retirement = run.calc("sc_retirement_deduction_indv") + expected_military = pay * armed * people["filer"] + expected_retirement = people["filer"] * np.where( + armed, + np.minimum(np.maximum(cap - pay, 0), pension), + np.minimum(cap, pension + pay), + ) + _assert_close(military[rows], expected_military[rows], "12-6-1171 deduction") + _assert_close(retirement[rows], expected_retirement[rows], "12-6-1170 deduction") + + # Armed forces status never lowers what a person deducts, and it changes + # the total only when the pay exceeds the 12-6-1170 cap. + total = military + retirement + armed_total = total[rows & armed] + noaa_total = total[rows & ~armed] + assert (armed_total >= noaa_total - TOLERANCE).all() + within_cap = (pay <= cap)[rows & armed] + _assert_close(armed_total[within_cap], noaa_total[within_cap], "within the cap") + assert (armed_total > noaa_total + TOLERANCE).any() + + +def test_south_carolina_takes_each_dollar_of_survivor_pay_on_one_line(run): + people = run.people + p = run.states.sc.tax.income.subtractions.retirement + rows = people["state"] == "SC" + armed = ~people["noaa"] + survivors = people["survivors"] * people["surviving"] * people["filer"] + cap = p.cap.calc(people["deceased_age"]) + assert (survivors[rows] > 0).any() + _assert_close( + run.calc("sc_military_deduction_survivors")[rows], + (survivors * armed)[rows], + "12-6-1171 survivor deduction", + ) + _assert_close( + run.calc("sc_retirement_deduction_survivors")[rows], + (np.minimum(survivors, cap) * ~armed)[rows], + "12-6-1170 survivor deduction", + ) + + +def test_rhode_island_tests_age_for_each_person(run): + people = run.people + p = run.states.ri.tax.income.agi.subtractions.taxable_retirement_income + rows = run.tax_units["state"] == "RI" + income = sum(run.calc(source) for source in p.sources) + military = run.calc("ri_military_retirement_subtraction") + capped = np.minimum(np.maximum(income - military, 0), p.cap) * people["filer"] + qualifies = run.year - people["age"] <= p.birth_year + eligible = run.calc("ri_retirement_income_subtraction_eligible") + subtraction = run.calc("ri_retirement_income_subtraction") + + expected = run.by_unit(capped * qualifies) * eligible + _assert_close(subtraction[rows], expected[rows], "Rhode Island per-person test") + assert (subtraction[rows] <= run.by_unit(capped)[rows] + TOLERANCE).all() + + # The draws include eligible couples with a spouse too young to qualify. + assert (rows & (eligible > 0) & (run.by_unit(capped * ~qualifies) > 0)).any() + + +def test_armed_forces_status_never_lowers_rhode_island_subtractions(run): + people = run.people + units = run.tax_units + rows = units["state"] == "RI" + military = run.by_unit( + run.calc("ri_military_retirement_subtraction") * people["filer"] + ) + total = military + run.calc("ri_retirement_income_subtraction") + assert ( + total[rows & ~units["noaa"]] >= total[rows & units["noaa"]] - TOLERANCE + ).all() diff --git a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/in_military_retirement_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/in_military_retirement_deduction.yaml index 033fd22097c..c6d5d6d9316 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/in_military_retirement_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/in_military_retirement_deduction.yaml @@ -61,3 +61,38 @@ military_retirement_pay_survivors: 10_000 output: in_military_retirement_deduction: 6_250 + 0.75 * (10_000 - 6_250) + +- name: Indiana does not deduct retirement pay from a uniformed service outside the armed forces before 2025. + period: 2024 + input: + people: + person1: + military_retirement_pay: 30_000 + military_retirement_pay_is_armed_forces: false + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: IN + output: + in_military_retirement_deduction: 0 + +- name: Indiana deducts retirement pay from all uniformed services from 2025. + period: 2025 + input: + people: + person1: + military_retirement_pay: 30_000 + military_retirement_pay_is_armed_forces: false + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: IN + output: + # 6,250 + 100% of (30,000 - 6,250). + in_military_retirement_deduction: 30_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.yaml index ab91d1bdb8a..555faa4e571 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.yaml @@ -55,3 +55,24 @@ output: nd_military_retirement_subtraction: 30_000 nd_subtractions: 30_000 + +- name: North Dakota does not subtract retirement pay from a uniformed service outside the armed forces. + period: 2024 + input: + state_code: ND + age: 70 + military_retirement_pay: 10_000 + military_retirement_pay_is_armed_forces: false + output: + nd_military_retirement_subtraction: 0 + +- name: North Dakota does not subtract a surviving spouse's annuity from a uniformed service outside the armed forces. + period: 2024 + input: + state_code: ND + age: 50 + is_surviving_spouse: true + military_retirement_pay_survivors: 10_000 + military_retirement_pay_is_armed_forces: false + output: + nd_military_retirement_subtraction: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.yaml index 91544b953c2..e2497bcb6f3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.yaml @@ -97,3 +97,13 @@ military_retirement_pay_survivors: 10_000 output: nm_armed_forces_retirement_pay_exemption_person: 30_000 + +- name: New Mexico does not exempt retirement pay from a uniformed service outside the armed forces. + period: 2022 + input: + state_code: NM + military_retirement_pay: 8_000 + military_retirement_pay_is_armed_forces: false + is_tax_unit_head_or_spouse: true + output: + nm_armed_forces_retirement_pay_exemption_person: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.yaml index fcd6f492584..27ec3d691b2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.yaml @@ -57,3 +57,16 @@ output: ri_military_retirement_subtraction: 20_000 ri_subtractions: 20_000 + +- name: Rhode Island leaves retirement pay from a uniformed service outside the armed forces to the general pension modification. + period: 2024 + input: + state_code: RI + age: 70 + military_retirement_pay: 10_000 + military_retirement_pay_is_armed_forces: false + output: + ri_military_retirement_subtraction: 0 + # Born 1954, on or before 1958; AGI 10,000 is under the limit; the + # 2024 cap of 20,000 does not bind. + ri_retirement_income_subtraction: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.yaml index 5d99228c5bb..666d475e1b7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.yaml @@ -11,6 +11,7 @@ period: 2022 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_pension_income: 20_000 output: @@ -32,6 +33,7 @@ period: 2025 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_pension_income: 30_000 output: @@ -42,6 +44,7 @@ period: 2025 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_pension_income: 50_000 output: @@ -52,6 +55,7 @@ period: 2025 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_pension_income: 75_000 output: @@ -110,6 +114,7 @@ period: 2022 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_401k_distributions: 10_000 taxable_403b_distributions: 5_000 @@ -121,6 +126,7 @@ period: 2023 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_401k_distributions: 10_000 taxable_403b_distributions: 5_000 @@ -132,6 +138,7 @@ period: 2024 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true taxable_401k_distributions: 10_000 taxable_403b_distributions: 5_000 @@ -143,6 +150,7 @@ period: 2022 input: state_code: RI + age: 75 ri_retirement_income_subtraction_eligible: true military_retirement_pay: 10_000 military_retirement_pay_survivors: 5_000 @@ -191,3 +199,75 @@ ri_military_retirement_subtraction: 20_000 ri_retirement_income_subtraction: 10_000 ri_subtractions: 30_000 + +# The age test applies to each spouse: only retirement income received by a +# taxpayer born on or before the threshold qualifies. The 2025 threshold +# year is 1959. +- name: 2025 - Joint, only the older spouse's pension qualifies + period: 2025 + absolute_error_margin: 1 + input: + people: + head: + age: 70 # born 1955, on or before 1959 + taxable_pension_income: 10_000 + spouse: + age: 60 # born 1965, after 1959 + taxable_pension_income: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: RI + output: + # AGI 40,000 < 133,750 and the head qualifies by age. + ri_retirement_income_subtraction_eligible: true + # min(10,000, 50,000) for the head; the spouse's 30,000 does not qualify. + ri_retirement_income_subtraction: 10_000 + +- name: 2025 - Joint, the younger spouse's pension does not qualify through the older spouse + period: 2025 + absolute_error_margin: 1 + input: + people: + head: + age: 60 # born 1965 + taxable_pension_income: 30_000 + spouse: + age: 70 # born 1955 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: RI + output: + ri_retirement_income_subtraction_eligible: true + ri_retirement_income_subtraction: 0 + +- name: 2025 - Joint, both spouses qualify by age + # The form's cutoff falls on March 1, 1959, and the model reads birth year + # only, so it counts a head born in 1959 as qualifying. + period: 2025 + absolute_error_margin: 1 + input: + people: + head: + age: 66 # born 1959, the threshold year + taxable_pension_income: 60_000 + spouse: + age: 70 # born 1955 + taxable_pension_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: RI + output: + # min(60,000, 50,000) + min(20,000, 50,000) = 70,000; AGI 80,000 < 133,750. + ri_retirement_income_subtraction: 70_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.yaml index ec271795ac0..4acb95fa87e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.yaml @@ -15,3 +15,13 @@ state_code: SC output: sc_military_deduction_indv: 0 + +- name: South Carolina does not deduct retirement pay from a uniformed service outside the armed forces. + period: 2022 + input: + state_code: SC + military_retirement_pay: 11_000 + military_retirement_pay_is_armed_forces: false + is_tax_unit_head_or_spouse: true + output: + sc_military_deduction_indv: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.yaml index 6a7bb85b71b..aa4c81c4de4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.yaml @@ -100,3 +100,14 @@ sc_military_deduction_indv: 20_000 sc_military_deduction_survivors: 0 sc_military_deduction: 20_000 + +- name: South Carolina does not deduct a surviving spouse's annuity from a uniformed service outside the armed forces. + period: 2022 + input: + state_code: SC + military_retirement_pay_survivors: 5_000 + military_retirement_pay_is_armed_forces: false + is_surviving_spouse: true + is_tax_unit_head_or_spouse: true + output: + sc_military_deduction_survivors: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.yaml index 6a177febff8..ae982c257cd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.yaml @@ -65,3 +65,74 @@ output: sc_retirement_deduction_indv: 5_000 + +- name: South Carolina counts NOAA or PHS retired pay, which gets no military deduction, toward the general deduction. + period: 2022 + input: + taxable_pension_income: 1_000 + military_retirement_pay: 11_000 + military_retirement_pay_is_armed_forces: false + age: 70 + is_tax_unit_head_or_spouse: true + state_code: SC + output: + # The pay is not armed forces pay, so 12-6-1171 deducts nothing. + sc_military_deduction_indv: 0 + # Line 1: 10,000 at 65 or older. Line 2: 0. Line 3: 10,000 - 0 = 10,000. + # Line 4: 1,000 + 11,000 - 0 = 12,000. Line 5: min(10,000, 12,000). + sc_retirement_deduction_indv: 10_000 + # 15,000 - (10,000 + 0) = 5,000. + sc_senior_exemption_person: 5_000 + +- name: A filer under 65 deducts the entire $750 of civilian retirement income (Revenue Ruling 22-11, Example 1). + period: 2022 + input: + taxable_pension_income: 750 + military_retirement_pay: 2_000 + military_retirement_pay_is_armed_forces: true + age: 60 + is_tax_unit_head_or_spouse: true + state_code: SC + output: + # 12-6-1171 deducts all 2,000 of armed forces retired pay. + sc_military_deduction_indv: 2_000 + # Line 1: 3,000 under 65. Line 2: 2,000. Line 3: 3,000 - 2,000 = 1,000. + # Line 4: 750 + 2,000 - 2,000 = 750. Line 5: min(1,000, 750) = 750. + sc_retirement_deduction_indv: 750 + +- name: South Carolina keeps armed forces retired pay, which gets the military deduction, off line 4. + period: 2022 + input: + taxable_pension_income: 1_000 + military_retirement_pay: 5_000 + military_retirement_pay_is_armed_forces: true + age: 70 + is_tax_unit_head_or_spouse: true + state_code: SC + output: + # 12-6-1171 deducts all 5,000 of armed forces retired pay. + sc_military_deduction_indv: 5_000 + # Line 1: 10,000. Line 2: 5,000. Line 3: 10,000 - 5,000 = 5,000. + # Line 4: 1,000 + 5,000 - 5,000 = 1,000. Line 5: min(5,000, 1,000) = 1,000. + sc_retirement_deduction_indv: 1_000 + +- name: A dependent's NOAA or PHS retired pay is not on the filer's line 4. + period: 2022 + input: + people: + head: + age: 70 + dependent: + age: 70 + military_retirement_pay: 11_000 + military_retirement_pay_is_armed_forces: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + households: + household: + members: [head, dependent] + state_code: SC + output: + sc_retirement_deduction_indv: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.yaml b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.yaml index 51226624c68..352afbfd7e5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.yaml @@ -169,3 +169,20 @@ output: # min(12_000, 10_000) sc_retirement_deduction_survivors: 10_000 + +- name: A surviving spouse deducts a NOAA or PHS survivor annuity, which gets no military deduction. + period: 2022 + input: + pension_survivors: 1_000 + military_retirement_pay_survivors: 4_000 + military_retirement_pay_is_armed_forces: false + is_surviving_spouse: true + age: 70 + deceased_spouse_age: 72 + state_code: SC + output: + # The annuity is not armed forces pay, so 12-6-1171 deducts nothing. + sc_military_deduction_survivors: 0 + # Line 1: 10,000, as the deceased spouse would be 72. + # Line 4: 1,000 + 4,000 - 0 = 5,000. min(5,000, 10,000) = 5,000. + sc_retirement_deduction_survivors: 5_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay.yaml b/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay.yaml new file mode 100644 index 00000000000..0ee674185c8 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay.yaml @@ -0,0 +1,15 @@ +- name: By default all military retirement pay is armed forces retirement pay. + period: 2024 + input: + military_retirement_pay: 10_000 + output: + armed_forces_retirement_pay: 10_000 + +- name: Retirement pay from a uniformed service outside the armed forces is not armed forces retirement pay. + period: 2024 + input: + military_retirement_pay: 10_000 + military_retirement_pay_is_armed_forces: false + output: + armed_forces_retirement_pay: 0 + military_retirement_income: 10_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay_survivors.yaml b/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay_survivors.yaml new file mode 100644 index 00000000000..05ef257787e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/person/retirement/armed_forces_retirement_pay_survivors.yaml @@ -0,0 +1,14 @@ +- name: By default all military survivor annuities are armed forces survivor annuities. + period: 2024 + input: + military_retirement_pay_survivors: 5_000 + output: + armed_forces_retirement_pay_survivors: 5_000 + +- name: A survivor annuity from a uniformed service outside the armed forces is not an armed forces survivor annuity. + period: 2024 + input: + military_retirement_pay_survivors: 5_000 + military_retirement_pay_is_armed_forces: false + output: + armed_forces_retirement_pay_survivors: 0 diff --git a/policyengine_us/variables/gov/states/in/tax/income/deductions/in_military_retirement_deduction.py b/policyengine_us/variables/gov/states/in/tax/income/deductions/in_military_retirement_deduction.py index e779f327503..7d4cf182004 100644 --- a/policyengine_us/variables/gov/states/in/tax/income/deductions/in_military_retirement_deduction.py +++ b/policyengine_us/variables/gov/states/in/tax/income/deductions/in_military_retirement_deduction.py @@ -25,8 +25,13 @@ def formula(tax_unit, period, parameters): # own return. head_or_spouse = person("is_tax_unit_head_or_spouse", period) # IC 6-3-2-4(a)(2) covers the service member and surviving spouse. - retirement_pay = person("military_retirement_pay", period) - survivor_pay = person("military_retirement_pay_survivors", period) + if p.uniformed_services_eligible: + retirement_pay = person("military_retirement_pay", period) + survivor_pay = person("military_retirement_pay_survivors", period) + else: + # Before 2025 the deduction covers the armed forces only. + retirement_pay = person("armed_forces_retirement_pay", period) + survivor_pay = person("armed_forces_retirement_pay_survivors", period) surviving_spouse = person("is_surviving_spouse", period) military_retirement = retirement_pay + survivor_pay * surviving_spouse excess = max_(0, military_retirement - p.base) diff --git a/policyengine_us/variables/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.py b/policyengine_us/variables/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.py index 1cd20c8e999..f6de66e12ed 100644 --- a/policyengine_us/variables/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.py +++ b/policyengine_us/variables/gov/states/nd/tax/income/subtractions/nd_military_retirement_subtraction.py @@ -14,7 +14,7 @@ class nd_military_retirement_subtraction(Variable): ) def formula(person, period, parameters): - retirement_pay = person("military_retirement_pay", period) - survivor_pay = person("military_retirement_pay_survivors", period) + retirement_pay = person("armed_forces_retirement_pay", period) + survivor_pay = person("armed_forces_retirement_pay_survivors", period) surviving_spouse = person("is_surviving_spouse", period) return retirement_pay + survivor_pay * surviving_spouse diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.py index d34111f6f19..1e783516d57 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_armed_forces_retirement_pay_exemption_person.py @@ -18,8 +18,8 @@ def formula(person, period, parameters): p = parameters( period ).gov.states.nm.tax.income.exemptions.armed_forces_retirement_pay - retirement_pay = person("military_retirement_pay", period) - survivor_pay = person("military_retirement_pay_survivors", period) + retirement_pay = person("armed_forces_retirement_pay", period) + survivor_pay = person("armed_forces_retirement_pay_survivors", period) surviving_spouse = person("is_surviving_spouse", period) armed_forces_retirement_pay = ( retirement_pay + survivor_pay * surviving_spouse * p.survivors_eligible diff --git a/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.py b/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.py index 5dda1163ea7..15c5e508778 100644 --- a/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.py +++ b/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/ri_military_retirement_subtraction.py @@ -18,7 +18,7 @@ class ri_military_retirement_subtraction(Variable): ) def formula_2023_01_01(person, period, parameters): - retirement_pay = person("military_retirement_pay", period) - survivor_pay = person("military_retirement_pay_survivors", period) + retirement_pay = person("armed_forces_retirement_pay", period) + survivor_pay = person("armed_forces_retirement_pay_survivors", period) surviving_spouse = person("is_surviving_spouse", period) return retirement_pay + survivor_pay * surviving_spouse diff --git a/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.py b/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.py index c89f5ead2cf..21c92a3dde7 100644 --- a/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.py +++ b/policyengine_us/variables/gov/states/ri/tax/income/agi/subtractions/taxable_retirement_income/ri_retirement_income_subtraction.py @@ -24,6 +24,13 @@ def formula(tax_unit, period, parameters): military_subtraction = person("ri_military_retirement_subtraction", period) retirement_income = max_(retirement_income - military_subtraction, 0) head_or_spouse = person("is_tax_unit_head_or_spouse", period) + # The RI-1040 worksheet has Primary and Spouse columns, and only the + # retirement income of a taxpayer born on or before the threshold + # date qualifies, so the age test applies to each person. + birth_year = person("birth_year", period) + age_eligible = birth_year <= p.birth_year # Cap applies per person, not per tax unit - capped_per_person = min_(retirement_income, p.cap) * head_or_spouse + capped_per_person = ( + min_(retirement_income, p.cap) * head_or_spouse * age_eligible + ) return tax_unit.sum(capped_per_person) diff --git a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.py b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.py index 32610f06fe7..cb80d65d1ae 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_indv.py @@ -15,4 +15,4 @@ class sc_military_deduction_indv(Variable): def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return person("military_retirement_pay", period) * head_or_spouse + return person("armed_forces_retirement_pay", period) * head_or_spouse diff --git a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.py b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.py index b9e7754b2b5..5d60428a786 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/military_retirement/sc_military_deduction_survivors.py @@ -20,7 +20,7 @@ def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) surviving_spouse = person("is_surviving_spouse", period) return ( - person("military_retirement_pay_survivors", period) + person("armed_forces_retirement_pay_survivors", period) * head_or_spouse * surviving_spouse ) diff --git a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.py b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.py index 1e4e257e4dc..aedb654f75a 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_indv.py @@ -19,13 +19,20 @@ def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) # line 1 max_deduction_allowed = p.cap.calc(age) - # line 2 - military_retirement_pay = person("military_retirement_pay", period) + # line 2: the deduction claimed under 12-6-1171 (12-6-1170(C)(1)), + # not gross military retirement pay. + military_retirement_deduction = person("sc_military_deduction_indv", period) # line 3 retirement_deduction_available = max_( - max_deduction_allowed - military_retirement_pay, 0 + max_deduction_allowed - military_retirement_deduction, 0 ) - # line 4 - retirement_income = add(person, period, p.sources) * head_or_spouse + # line 4: South Carolina counts income from all federal, state and + # local public employee retirement plans (12-6-1170(A)(2)). NOAA and + # PHS retired pay gets no 12-6-1171 deduction, so it counts here. + retirement_income = ( + add(person, period, p.sources) + + person("military_retirement_pay", period) + - person("armed_forces_retirement_pay", period) + ) * head_or_spouse # line 5 return min_(retirement_deduction_available, retirement_income) diff --git a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.py b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.py index 3d84d79aba2..9487e31e2da 100644 --- a/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.py +++ b/policyengine_us/variables/gov/states/sc/tax/income/subtractions/retirement/sc_retirement_deduction_survivors.py @@ -36,7 +36,15 @@ def formula(person, period, parameters): # spouse, not to other beneficiaries of an inherited account. head_or_spouse = person("is_tax_unit_head_or_spouse", period) surviving_spouse = person("is_surviving_spouse", period) + # NOAA and PHS survivor annuities get no 12-6-1171 deduction, so + # they count here too. retirement_income_survivors = ( - person("pension_survivors", period) * head_or_spouse * surviving_spouse + ( + person("pension_survivors", period) + + person("military_retirement_pay_survivors", period) + - person("armed_forces_retirement_pay_survivors", period) + ) + * head_or_spouse + * surviving_spouse ) return min_(retirement_income_survivors, max_deduction_allowed) diff --git a/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay.py b/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay.py new file mode 100644 index 00000000000..beafd11f944 --- /dev/null +++ b/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class armed_forces_retirement_pay(Variable): + value_type = float + entity = Person + label = "Armed forces retirement pay" + unit = USD + definition_period = YEAR + documentation = ( + "Military retirement pay from service in the armed forces, excluding " + "retirement pay from the NOAA and Public Health Service commissioned " + "corps." + ) + reference = ("https://uscode.house.gov/us/usc/t10/s101/",) + + def formula(person, period, parameters): + armed_forces = person("military_retirement_pay_is_armed_forces", period) + return person("military_retirement_pay", period) * armed_forces diff --git a/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay_survivors.py b/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay_survivors.py new file mode 100644 index 00000000000..d8b20578a1a --- /dev/null +++ b/policyengine_us/variables/household/income/person/retirement/armed_forces_retirement_pay_survivors.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class armed_forces_retirement_pay_survivors(Variable): + value_type = float + entity = Person + label = "Armed forces retirement survivor benefit payments" + unit = USD + definition_period = YEAR + documentation = ( + "Military retirement survivor benefit payments from service in the " + "armed forces, excluding survivor annuities from the NOAA and Public " + "Health Service commissioned corps." + ) + reference = ("https://uscode.house.gov/us/usc/t10/s101/",) + + def formula(person, period, parameters): + armed_forces = person("military_retirement_pay_is_armed_forces", period) + return person("military_retirement_pay_survivors", period) * armed_forces diff --git a/policyengine_us/variables/household/income/person/retirement/military_retirement_pay_is_armed_forces.py b/policyengine_us/variables/household/income/person/retirement/military_retirement_pay_is_armed_forces.py new file mode 100644 index 00000000000..9e21c7fa658 --- /dev/null +++ b/policyengine_us/variables/household/income/person/retirement/military_retirement_pay_is_armed_forces.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class military_retirement_pay_is_armed_forces(Variable): + value_type = bool + entity = Person + label = "Military retirement pay is from the armed forces" + definition_period = YEAR + default_value = True + documentation = ( + "Whether this person's military_retirement_pay and " + "military_retirement_pay_survivors come from service in the armed " + "forces (the Army, Navy, Air Force, Marine Corps, Space Force and " + "Coast Guard, 10 U.S.C. 101(a)(4)). Set it to false for retirement " + "pay or survivor annuities from the commissioned corps of the " + "National Oceanic and Atmospheric Administration or the Public " + "Health Service, which are uniformed services (10 U.S.C. 101(a)(5)) " + "but not armed forces. Some state exclusions cover the armed forces " + "only." + ) + reference = ("https://uscode.house.gov/us/usc/t10/s101/",)