diff --git a/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md b/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md new file mode 100644 index 00000000000..65b2ac46d2a --- /dev/null +++ b/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md @@ -0,0 +1 @@ +Make household net income and state income tax use each state's own final income tax: drop the Wisconsin and New Jersey overrides in `state_income_tax`, add the Vermont child care contribution, Colorado alternative minimum tax and contributed Michigan surtax to state tax before refundable credits, and apply Mississippi's nonrefundable credits there. diff --git a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml index c3877dbb5d1..9c16160e1a5 100644 --- a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml @@ -6,6 +6,7 @@ values: - az_income_tax_before_refundable_credits - ca_income_tax_before_refundable_credits - co_income_tax_before_refundable_credits + - co_amt # DR 0104 line 14 alternative minimum tax, added before refundable credits - ct_income_tax_before_refundable_credits - dc_income_tax_before_refundable_credits - de_income_tax_before_refundable_credits @@ -23,9 +24,10 @@ values: - ma_income_tax_before_refundable_credits - md_income_tax_before_refundable_credits - mi_income_tax_before_refundable_credits + - mi_surtax # contributed surtax, zero unless gov.contrib.states.mi.surtax.in_effect - mn_income_tax_before_refundable_credits - mo_income_tax_before_refundable_credits - - ms_income_tax_before_credits_unit + - ms_income_tax_before_refundable_credits - mt_income_tax_before_refundable_credits_unit - nc_income_tax - nd_income_tax_before_refundable_credits @@ -44,6 +46,7 @@ values: - ut_income_tax_before_refundable_credits - va_income_tax_before_refundable_credits - vt_income_tax_before_refundable_credits + - vt_child_care_contributions # Form IN-111 line 21 child care contribution, added before refundable credits - wi_income_tax_before_refundable_credits - wv_income_tax_before_refundable_credits diff --git a/policyengine_us/reforms/states/mi/surtax.py b/policyengine_us/reforms/states/mi/surtax.py index a450f704f9b..c60e5f323af 100644 --- a/policyengine_us/reforms/states/mi/surtax.py +++ b/policyengine_us/reforms/states/mi/surtax.py @@ -43,6 +43,8 @@ def apply(self): def create_mi_surtax_reform(parameters, period, bypass: bool = False): # Always return the reform - the in_effect check happens in the formula + # Keep this unconditional: the baseline state_income_tax_before_refundable_credits + # list adds mi_surtax, so the variable must exist in every system. return create_mi_surtax() diff --git a/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py new file mode 100644 index 00000000000..10abc0b5d0e --- /dev/null +++ b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py @@ -0,0 +1,510 @@ +"""State income tax accounting invariants, for every state. + +Household net income does not read each state's own final income tax. It +subtracts ``household_state_tax_before_refundable_credits`` and adds +``household_refundable_state_tax_credits``, which sum every state's tax before +refundable credits and every state's refundable credits from two cross-state +lists. A state whose final tax applies a rule that those two components do not +carry is then right in its own ``xx_income_tax`` and wrong in household net +income. Five cases shipped: the Wisconsin retirement income exclusion +election, the Vermont child care contribution, the Colorado alternative +minimum tax, Mississippi's nonrefundable credits, and the contributed Michigan +surtax. + +Three properties: + +1. Structure, with no simulation. Where a state's ``xx_income_tax`` is a sum, + it adds exactly that state's members of the before-refundable list and + subtracts exactly its members of the refundable list. +2. ``state_income_tax`` equals the state's own ``xx_income_tax`` wherever the + model defines one, and is zero in the states with no modelled income tax. +3. A household's state tax before refundable credits, less its refundable + state credits, equals the ``state_income_tax`` of all its tax units plus + their ``state_use_tax``. Use tax is the one intended difference: the + household pays it to the state, but it is not an income tax + (``state_income_tax_excludes_use_tax.yaml``). + +Properties 1 and 2 find a new state's gap. Property 3 holds by construction +while ``state_income_tax`` is the plain sum of the two lists; it fails if an +override inside ``state_income_tax``, like the Wisconsin and New Jersey ones +this test replaced, comes back. + +Properties 2 and 3 must hold for every tax unit and household, so the tests +draw a seeded population in each of the 50 states and DC and run it as one +vectorized simulation. Where a state's ``xx_income_tax`` is a sum, property 1 +already implies property 2, so the states that compute it with a formula get +more: a seeded 2023 population, for Montana's separate filing on one form, and +Hypothesis draws in 2023 and 2025. YAML cases cannot express these +properties: each compares two computed totals on every row instead of one +total with a stated amount. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, Phase, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +YEAR = 2025 +SEED = 20261009 +N_RANDOM_PER_STATE = 6 +TOLERANCE = 0.05 # dollars; float32 sums of five- and six-figure amounts + +STATES = ( + "AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS " + "MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY" +).split() + +# The model has no income tax for these states. The set is asserted against +# the variable registry, so a state cannot drop out of the properties unnoticed. +STATES_WITHOUT_INCOME_TAX = {"AK", "FL", "NV", "SD", "TN", "TX", "WY"} + +# These states compute ``xx_income_tax`` with a formula, so property 1 cannot +# read its parts. Property 2 covers them. +STATES_WITH_FORMULA_INCOME_TAX = { + # Lower of the joint return and separate returns on one form, each after + # refundable credits. + "MT", + # No refundable credits in baseline, so the before-refundable list holds + # nc_income_tax itself. The contributed EITC and child care credit reforms + # net their refundable credits inside nc_income_tax, which keeps NC out of + # the refundable list. + "NC", + # No tax at or below the filing threshold. The zero-out is in both + # nj_income_tax_before_refundable_credits and nj_income_tax; the second + # copy keeps tests that set the first as an input right. + "NJ", + # Tax before refundable credits less refundable credits, both following + # the retirement income exclusion election. + "WI", +} + + +def _list_members(parameter): + return set(parameter(f"{YEAR}-01-01")) + + +def _state_members(members, state): + return {member for member in members if member.startswith(f"{state.lower()}_")} + + +@pytest.fixture(scope="module") +def before_refundable_list(): + return _list_members( + system.parameters.gov.states.household.state_income_tax_before_refundable_credits + ) + + +@pytest.fixture(scope="module") +def refundable_list(): + return _list_members( + system.parameters.gov.states.household.state_refundable_credits + ) + + +def test_states_without_an_income_tax_variable_are_the_expected_ones(): + without = { + state + for state in STATES + if f"{state.lower()}_income_tax" not in system.variables + } + assert without == STATES_WITHOUT_INCOME_TAX + + +def test_states_with_a_formula_income_tax_are_the_expected_ones(): + with_formula = { + state + for state in STATES + if state not in STATES_WITHOUT_INCOME_TAX + and system.variables[f"{state.lower()}_income_tax"].adds is None + } + assert with_formula == STATES_WITH_FORMULA_INCOME_TAX + + +def test_list_members_are_defined_variables(before_refundable_list, refundable_list): + undefined = (before_refundable_list | refundable_list) - set(system.variables) + assert not undefined + + +def test_household_refundable_list_matches_the_state_refundable_list(refundable_list): + household_list = _list_members( + system.parameters.gov.household.household_refundable_state_credits + ) + assert household_list == refundable_list + + +@pytest.mark.parametrize( + "state", + sorted(set(STATES) - STATES_WITHOUT_INCOME_TAX - STATES_WITH_FORMULA_INCOME_TAX), +) +def test_state_income_tax_parts_are_the_states_list_members( + state, before_refundable_list, refundable_list +): + own = system.variables[f"{state.lower()}_income_tax"] + assert set(own.adds) == _state_members(before_refundable_list, state) + assert set(own.subtracts or []) == _state_members(refundable_list, state) + + +# Each archetype is a list of tax units in one household. All inputs are leaf +# facts. A child is an age, or a dict of person inputs. +ARCHETYPES = [ + # Single worker. + [{"adults": [{"age": 30, "employment_income": 30_000}]}], + # Low-income single parent who rents: refundable state credits. + [ + { + "adults": [{"age": 32, "employment_income": 18_000, "rent": 9_000}], + "children": [3, 7], + } + ], + # Single parent who pays for child care: nonrefundable state credits. + [ + { + "adults": [{"age": 30, "employment_income": 30_000}], + "children": [3, 5], + "spm_unit": {"spm_unit_pre_subsidy_childcare_expenses": 6_000}, + } + ], + # Married couple with two children. + [ + { + "adults": [ + {"age": 40, "employment_income": 60_000}, + {"age": 38, "employment_income": 40_000}, + ], + "children": [5, 9], + } + ], + # Single retiree with a pension and Social Security. + [ + { + "adults": [ + { + "age": 70, + "taxable_private_pension_income": 40_000, + "social_security_retirement": 24_000, + } + ] + } + ], + # Retired couple with pensions, Social Security and investment income. + [ + { + "adults": [ + { + "age": 68, + "taxable_private_pension_income": 30_000, + "social_security_retirement": 20_000, + "long_term_capital_gains": 10_000, + "taxable_interest_income": 3_000, + }, + { + "age": 72, + "taxable_private_pension_income": 30_000, + "social_security_retirement": 20_000, + }, + ] + } + ], + # High earner with capital gains and dividends. + [ + { + "adults": [ + { + "age": 50, + "employment_income": 400_000, + "long_term_capital_gains": 100_000, + "qualified_dividend_income": 20_000, + } + ] + } + ], + # Self-employed parent who rents. + [ + { + "adults": [{"age": 45, "self_employment_income": 50_000, "rent": 12_000}], + "children": [10], + } + ], + # Very low earner, below state filing thresholds. + [{"adults": [{"age": 25, "employment_income": 5_000}]}], + # Elderly renter living on Social Security. + [{"adults": [{"age": 75, "social_security_retirement": 12_000, "rent": 7_000}]}], + # Low-income retiree who rents: property tax and homestead credits. + [ + { + "adults": [ + {"age": 67, "taxable_private_pension_income": 16_313, "rent": 12_000} + ] + } + ], + # Two tax units in one household: parents and an adult child who works. + [ + { + "adults": [ + {"age": 58, "employment_income": 70_000}, + {"age": 57, "taxable_private_pension_income": 25_000}, + ] + }, + {"adults": [{"age": 24, "employment_income": 28_000}]}, + ], +] + + +def _random_tax_unit(rng): + adults = [] + for _ in range(int(rng.integers(1, 3))): + age = int(rng.integers(19, 90)) + adults.append( + { + "age": age, + "employment_income": float( + round(rng.choice([0, 0, rng.uniform(0, 150_000)])) + ), + "self_employment_income": float( + round(rng.choice([0, 0, 0, rng.uniform(-20_000, 80_000)])) + ), + "taxable_private_pension_income": float( + round(rng.choice([0, rng.uniform(0, 90_000)]) if age >= 55 else 0) + ), + "social_security_retirement": float( + round( + rng.choice([0, rng.uniform(5_000, 40_000)]) if age >= 62 else 0 + ) + ), + "long_term_capital_gains": float( + round(rng.choice([0, 0, rng.uniform(-5_000, 60_000)])) + ), + "taxable_interest_income": float( + round(rng.choice([0, rng.uniform(0, 8_000)])) + ), + "rent": float(round(rng.choice([0, rng.uniform(3_000, 24_000)]))), + "real_estate_taxes": float( + round(rng.choice([0, 0, rng.uniform(500, 9_000)])) + ), + } + ) + children = [ + int(rng.integers(0, 17)) for _ in range(int(rng.choice([0, 0, 1, 2, 3]))) + ] + return {"adults": adults, "children": children} + + +def _situation(households, year): + """Build a situation from (state, tax units) pairs, one per household.""" + people, tax_units, spm_units, household_entities = {}, {}, {}, {} + + def at_year(inputs): + return {variable: {year: value} for variable, value in inputs.items()} + + for household_index, (state, household_tax_units) in enumerate(households): + household_members = [] + spm_unit_inputs = {} + for unit_index, unit in enumerate(household_tax_units): + key = f"{household_index}_{unit_index}" + members = [] + for adult_index, adult in enumerate(unit["adults"]): + name = f"adult_{adult_index}_{key}" + members.append(name) + people[name] = at_year(adult) + for child_index, child in enumerate(unit.get("children", [])): + name = f"child_{child_index}_{key}" + members.append(name) + people[name] = at_year( + child if isinstance(child, dict) else {"age": child} + ) + tax_units[f"tax_unit_{key}"] = {"members": members} + spm_unit_inputs.update(unit.get("spm_unit", {})) + household_members += members + spm_units[f"spm_unit_{household_index}"] = { + "members": household_members, + **at_year(spm_unit_inputs), + } + household_entities[f"household_{household_index}"] = { + "members": household_members, + "state_code": {year: state}, + } + return { + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": household_entities, + } + + +def _seeded_households(): + rng = np.random.default_rng(SEED) + households = [] + for state in STATES: + households += [(state, archetype) for archetype in ARCHETYPES] + households += [ + (state, [_random_tax_unit(rng)]) for _ in range(N_RANDOM_PER_STATE) + ] + return households + + +def _tax_unit_state(simulation, year): + tax_unit = simulation.populations["tax_unit"] + return np.asarray(tax_unit.household("state_code", year).decode_to_str()) + + +def _tax_unit(simulation, variable, year=YEAR): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def _household(simulation, variable, year=YEAR): + return np.asarray( + simulation.calculate(variable, year, map_to="household"), dtype=float + ) + + +def _check_state_income_tax_equals_own_income_tax(simulation, state, year=YEAR): + in_state = _tax_unit_state(simulation, year) == state + state_income_tax = _tax_unit(simulation, "state_income_tax", year)[in_state] + if state in STATES_WITHOUT_INCOME_TAX: + np.testing.assert_array_equal(state_income_tax, 0) + return + own_income_tax = _tax_unit(simulation, f"{state.lower()}_income_tax", year) + np.testing.assert_allclose( + state_income_tax, own_income_tax[in_state], atol=TOLERANCE, rtol=0 + ) + + +def _check_household_totals(simulation, year=YEAR): + before_refundable = _household( + simulation, "household_state_tax_before_refundable_credits", year + ) + refundable = _household(simulation, "household_refundable_state_tax_credits", year) + state_income_tax = _household(simulation, "state_income_tax", year) + use_tax = _household(simulation, "state_use_tax", year) + np.testing.assert_allclose( + before_refundable - refundable, + state_income_tax + use_tax, + atol=TOLERANCE, + rtol=0, + ) + + +@pytest.fixture(scope="module") +def simulation(): + return Simulation(situation=_situation(_seeded_households(), YEAR)) + + +@pytest.fixture(scope="module") +def tax_unit_state(simulation): + return _tax_unit_state(simulation, YEAR) + + +def test_population_exercises_the_properties(simulation, tax_unit_state): + state_income_tax = _tax_unit(simulation, "state_income_tax") + for state in STATES: + assert (tax_unit_state == state).sum() >= len(ARCHETYPES) + N_RANDOM_PER_STATE + # Net refunds from refundable credits, in many states. + assert len(set(tax_unit_state[state_income_tax < 0])) >= 10 + # Use tax, the labelled exception in property 3. + assert (_household(simulation, "state_use_tax") > 0).sum() >= 10 + # Households with more than one tax unit. + households = simulation.populations["household"].count + assert simulation.populations["tax_unit"].count > households + # Rules that were once missing from household net income. Leaf inputs + # cannot produce a Colorado alternative minimum tax or the contributed + # Michigan surtax; property 1 covers those. + elected = np.asarray( + simulation.calculate("wi_retirement_income_exclusion_elected", YEAR) + ) + assert elected.sum() >= 2 + assert (_tax_unit(simulation, "vt_child_care_contributions") > 0).sum() >= 2 + assert (_tax_unit(simulation, "ms_non_refundable_credits") > 0).sum() >= 1 + + +@pytest.mark.parametrize("state", STATES) +def test_state_income_tax_equals_the_states_own_income_tax(simulation, state): + _check_state_income_tax_equals_own_income_tax(simulation, state) + + +def test_household_state_tax_totals_equal_state_income_tax_plus_use_tax(simulation): + _check_household_totals(simulation) + + +# Whole-dollar amounts keep float32 sums exact. The fixed values sit on and +# beside Wisconsin's $24,000 exclusion cap and New Jersey's filing thresholds. +amounts = st.one_of( + st.just(0.0), + st.sampled_from([9_999.0, 10_000.0, 10_001.0, 20_000.0, 24_000.0, 24_001.0]), + st.integers(1, 150_000).map(float), +) + + +@st.composite +def formula_state_households(draw): + adults = [] + for _ in range(draw(st.integers(1, 2))): + age = draw(st.one_of(st.sampled_from([64, 65, 66, 67]), st.integers(19, 90))) + adults.append( + { + "age": age, + "employment_income": draw(amounts), + "self_employment_income": draw( + st.one_of(st.just(0.0), st.integers(-30_000, 80_000).map(float)) + ), + "taxable_private_pension_income": draw(amounts) if age >= 55 else 0.0, + "social_security_retirement": ( + draw(st.integers(0, 40_000).map(float)) if age >= 62 else 0.0 + ), + "long_term_capital_gains": draw( + st.one_of(st.just(0.0), st.integers(-8_000, 80_000).map(float)) + ), + "rent": draw( + st.one_of(st.just(0.0), st.integers(1, 24_000).map(float)) + ), + } + ) + children = draw(st.lists(st.integers(0, 16), max_size=3)) + unit = {"adults": adults, "children": children} + if children: + unit["spm_unit"] = { + "spm_unit_pre_subsidy_childcare_expenses": draw( + st.one_of(st.just(0.0), st.integers(1, 12_000).map(float)) + ) + } + state = draw(st.sampled_from(sorted(STATES_WITH_FORMULA_INCOME_TAX))) + return state, [unit] + + +def test_formula_states_keep_the_properties_in_2023(): + # Montana lets spouses file separately on one form through 2023. + households = [ + (state, units) + for state, units in _seeded_households() + if state in STATES_WITH_FORMULA_INCOME_TAX + ] + simulation = Simulation(situation=_situation(households, 2023)) + separately = np.asarray(simulation.calculate("mt_files_separately", 2023)) + assert separately.sum() >= 1 + for state in sorted(STATES_WITH_FORMULA_INCOME_TAX): + _check_state_income_tax_equals_own_income_tax(simulation, state, 2023) + _check_household_totals(simulation, 2023) + + +@settings( + max_examples=4, + deadline=None, + derandomize=True, + # Each example builds a full simulation, so shrinking a failure could run + # for hours in CI. A failing example is reported unshrunk. + phases=[Phase.explicit, Phase.generate], + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(formula_state_households(), min_size=10, max_size=40), + # Montana let spouses file separately on one form through 2023, and the + # Wisconsin retirement income exclusion starts in 2025. + st.sampled_from([2023, 2025]), +) +def test_formula_states_keep_the_properties(households, year): + simulation = Simulation(situation=_situation(households, year)) + for state in sorted(STATES_WITH_FORMULA_INCOME_TAX): + _check_state_income_tax_equals_own_income_tax(simulation, state, year) + _check_household_totals(simulation, year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml index 40e7b93ff58..20e9ea37f6f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml @@ -48,3 +48,23 @@ ms_income_tax_before_credits_unit: 500 output: ms_income_tax: 200 + +- name: Refundable credits reduce Mississippi income tax + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 500 + ms_non_refundable_credits: 300 + ms_refundable_credits: 50 + output: + ms_income_tax: 150 + +- name: Refundable credits can make Mississippi income tax negative + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 200 + ms_non_refundable_credits: 300 + ms_refundable_credits: 50 + output: + ms_income_tax: -50 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml new file mode 100644 index 00000000000..7d038a74991 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml @@ -0,0 +1,17 @@ +- name: Nonrefundable credits reduce Mississippi income tax + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 500 + ms_non_refundable_credits: 300 + output: + ms_income_tax_before_refundable_credits: 200 + +- name: Nonrefundable credits cannot reduce Mississippi income tax below zero + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 200 + ms_non_refundable_credits: 300 + output: + ms_income_tax_before_refundable_credits: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml index 97eecf3e204..eb23bcccb64 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml @@ -35,3 +35,69 @@ output: nj_income_tax: 0 state_income_tax: 0 + +- name: Vermont state income tax includes the child care contribution on self-employment income. + # 2024 Form IN-111: line 20 (income tax after credits) + line 21 (child + # care contribution) + line 22 (use tax) = line 23 (total Vermont taxes); + # refundable credits follow on line 26c. + # Child Care Contribution Worksheet: Schedule SE line 6 x 0.5 x 0.0011. + # https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=12 + # Schedule SE line 6 = 100,000 x 0.9235 = 92,350; contribution = + # 92,350 x 0.5 x 0.0011 = 50.79; total = 1,000 + 50.79 - 200 = 850.79. + period: 2024 + absolute_error_margin: 0.01 + input: + state_code: VT + self_employment_income: 100_000 + vt_income_tax_before_refundable_credits: 1_000 + vt_refundable_credits: 200 + output: + vt_child_care_contributions: 50.79 + vt_income_tax: 850.79 + state_income_tax: 850.79 + +- name: Mississippi state income tax subtracts nonrefundable credits. + # 2025 Mississippi resident instructions: head of family exemption $8,000, + # $1,500 per dependent, standard deduction $3,400; tax is 4.4% of taxable + # income over $10,000 (line 17). Credits on lines 18 and 19 reduce it. + # https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8 + # Taxable income = 30,000 - 3,400 - 8,000 - 3,000 = 15,600; + # tax = 0.044 x 5,600 = 246.40; less a credit of 159 = 87.40. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + child1: + age: 3 + child2: + age: 5 + tax_units: + tax_unit: + members: [parent, child1, child2] + ms_cdcc: 159 + households: + household: + members: [parent, child1, child2] + state_code: MS + output: + ms_income_tax_before_credits_unit: 246.40 + ms_income_tax_before_refundable_credits: 87.40 + ms_income_tax: 87.40 + state_income_tax: 87.40 + +- name: Colorado state income tax includes the alternative minimum tax. + # 2025 Form DR 0104: line 13 (tax) + line 14 (alternative minimum tax) + + # line 15 = line 16, before the refundable credits on line 31. + # https://tax.colorado.gov/sites/tax/files/documents/Book104_2025.pdf#page=11 + period: 2025 + input: + state_code: CO + co_income_tax_before_refundable_credits: 4_000 + co_amt: 500 + co_refundable_credits: 100 + output: + co_income_tax: 4_400 + state_income_tax: 4_400 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml index 8ca8a254933..7017abac77c 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml @@ -131,3 +131,32 @@ household_health_costs: 0 output: household_net_income: 29_000 + +- name: Household net income of a Wisconsin retiree reflects the retirement income exclusion. + # 2025 Wisconsin Form 1 instructions, Standard Deduction Table (page 35) + # and Tax Table (page 38), with exemptions of $700 + $250: + # with credits, line 7 = $41,250; standard deduction $10,956; taxable + # income $29,344; tax $1,159. + # with the Schedule SB Line 16 exclusion of $24,000, line 7 = $17,250; + # standard deduction $13,560; taxable income $2,740; tax $96. + # https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf#page=35 + # Federal income tax is set to zero to isolate the state tax: + # $41,250 - $96 = $41,154. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 41_250 + tax_units: + tax_unit: + members: [person1] + income_tax_before_refundable_credits: 0 + income_tax_refundable_credits: 0 + households: + household: + members: [person1] + state_code: WI + output: + household_net_income: 41_154 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml new file mode 100644 index 00000000000..e1f251326e3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml @@ -0,0 +1,74 @@ +- name: Wisconsin retiree keeps the homestead credit when it beats the retirement income exclusion + # 2025 Form 1 instructions (https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf): + # line 7 = $16,313; standard deduction $13,560 (page 35); exemptions + # $950; taxable income $1,803; tax $65 (page 38). The renter's school + # property tax credit (line 16a) exceeds $65, so net tax is $0. + # 2025 Schedule H instructions (https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleH-inst.pdf): + # household income $16,313 -> Table A (page 25) gives $722; rent + # constituting property taxes is capped at $1,460; $1,460 - $722 = $738 + # -> Table B (page 26) gives a credit of $588. + # Claiming the Schedule SB Line 16 subtraction for 2025 would bar the + # homestead credit (Schedule H instructions, page 4, Step 2 question h) + # and leave a tax of $0, so the filer keeps the credits: $0 - $588 = -$588. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 16_313 + rent: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_homestead_credit: 588 + wi_retirement_income_exclusion_elected: false + wi_income_tax: -588 + state_income_tax: -588 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 588 + +- name: Wisconsin filer who elects the retirement income exclusion for 2025 has no refundable state credits + # 2025 Schedule SB instructions, page 7, Line 16: no credit on Form 1 lines + # 13-20 and 30-35. Tax with credits = max(0, 5,000 - 500) - (200 + 300) = + # 4,000, above the exclusion tax of 3,000. + period: 2025 + input: + state_code: WI + wi_income_tax_before_credits: 5_000 + wi_non_refundable_credits: 500 + wi_earned_income_credit: 200 + wi_homestead_credit: 300 + wi_retirement_income_exclusion_amount: 24_000 + wi_retirement_income_exclusion_tax: 3_000 + state_use_tax: 0 + output: + wi_retirement_income_exclusion_elected: true + household_state_tax_before_refundable_credits: 3_000 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin filer who elects the retirement income exclusion for 2026 keeps the homestead credit + # 2025 Wisconsin Act 174, section 7, bars only credits under section 71.07; + # the homestead credit is under section 71.54 and the earned income credit + # is under 71.07. Tax with credits = max(0, 5,000 - 500) - (200 + 300) = + # 4,000; tax with the exclusion = 3,000 - 300 = 2,700. + # https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2 + period: 2026 + input: + state_code: WI + wi_income_tax_before_credits: 5_000 + wi_non_refundable_credits: 500 + wi_earned_income_credit: 200 + wi_homestead_credit: 300 + wi_retirement_income_exclusion_amount: 24_000 + wi_retirement_income_exclusion_tax: 3_000 + state_use_tax: 0 + output: + wi_retirement_income_exclusion_elected: true + household_state_tax_before_refundable_credits: 3_000 + household_refundable_state_tax_credits: 300 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml new file mode 100644 index 00000000000..03a80e94c46 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml @@ -0,0 +1,243 @@ +# Wisconsin retirees who claim the Schedule SB Line 16 retirement income +# subtraction owe the tax on the reduced income. For 2025 they may not claim +# any credit on Form 1 lines 13-20 and 30-35. The household totals must +# carry the tax and credits of the return the filer elects. +# +# Expected values come from the 2025 Wisconsin Form 1 instructions: +# https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf +# Standard Deduction Table, pages 35-37 (keyed to line 7, Wisconsin income). +# Tax Table, pages 38-43 (keyed to line 11, taxable income). +# Line 10 exemptions, page 15: $700 per filer plus $250 if 65 or older. +# and the 2025 Schedule SB instructions, Line 16 (up to $24,000 per filer +# aged 67 or older, $48,000 on a joint return when both are): +# https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7 +# +# Social Security is not taxed by Wisconsin, and 30% of a net long-term +# capital gain is excluded, so line 7 below is pension + 70% of the gain. + +- name: Wisconsin retiree, $20,250 pension, claims the exclusion and owes no tax + # With credits: line 7 = $20,250 + $3,500 = $23,750; standard deduction + # $13,056; taxable income $23,750 - $13,056 - $950 = $9,744; tax $341. + # With the exclusion: line 16 = $20,250; line 7 = $3,500; tax $0. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 20_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_income_tax_before_credits: 341 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 0 + state_income_tax: 0 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree, $40,250 pension, claims the exclusion and owes $184 + # With credits: line 7 = $43,750; standard deduction $10,656; taxable + # income $43,750 - $10,656 - $950 = $32,144; tax $1,282. + # With the exclusion: line 16 = $24,000; line 7 = $19,750; standard + # deduction $13,536; taxable income $5,264; tax $184. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 40_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_income_tax_before_credits: 1_282 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 184 + state_income_tax: 184 + household_state_tax_before_refundable_credits: 184 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree, $59,250 pension, claims the exclusion and owes $1,036 + # With credits: line 7 = $62,750; standard deduction $8,376; taxable + # income $62,750 - $8,376 - $950 = $53,424; tax $2,246. + # With the exclusion: line 16 = $24,000; line 7 = $38,750; standard + # deduction $11,256; taxable income $26,544; tax $1,036. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 59_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + wi_income_tax: 1_036 + state_income_tax: 1_036 + household_state_tax_before_refundable_credits: 1_036 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin couple both aged 68 claim the $48,000 exclusion and owe no tax + # With credits: line 7 = $70,250; standard deduction $16,795; exemptions + # $1,900; taxable income $51,555; tax $2,092. + # With the exclusion: line 16 = $48,000; line 7 = $22,250; standard + # deduction $25,110; taxable income $0; tax $0. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 40_250 + person2: + age: 68 + taxable_private_pension_income: 30_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: WI + output: + wi_income_tax_before_credits: 2_092 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 0 + state_income_tax: 0 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree who rents claims the exclusion and forgoes the school property tax credit + # With credits: line 7 = $41,250; standard deduction $10,956; taxable + # income $29,344; tax $1,159, less a renter's school property tax credit + # of at most $300 (line 16a), so at least $859. + # With the exclusion: line 7 = $17,250; standard deduction $13,560; + # taxable income $2,740; tax $96, with no credit. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 41_250 + rent: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + wi_income_tax: 96 + state_income_tax: 96 + household_state_tax_before_refundable_credits: 96 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin filer aged 66 cannot claim the exclusion + # Line 7 = $41,250; standard deduction $10,956; taxable income $29,344; + # tax $1,159. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 66 + taxable_private_pension_income: 41_250 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + wi_income_tax: 1_159 + state_income_tax: 1_159 + household_state_tax_before_refundable_credits: 1_159 + household_refundable_state_tax_credits: 0 + +- name: Vermont household state tax includes the child care contribution on self-employment income + # 2024 Form IN-111 line 21, Child Care Contribution Worksheet: Schedule SE + # line 6 x 0.5 x 0.0011 = 100,000 x 0.9235 x 0.5 x 0.0011 = 50.79, added + # to income tax after credits (line 20) before refundable credits (26c). + # https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=12 + period: 2024 + absolute_error_margin: 0.01 + input: + state_code: VT + self_employment_income: 100_000 + vt_income_tax_before_refundable_credits: 1_000 + vt_refundable_credits: 200 + output: + household_state_tax_before_refundable_credits: 1_050.79 + household_refundable_state_tax_credits: 200 + +- name: Mississippi household state tax subtracts nonrefundable credits + # 2025 Mississippi resident instructions: head of family exemption $8,000, + # $1,500 per dependent, standard deduction $3,400; tax is 4.4% of taxable + # income over $10,000 (line 17). Credits on lines 18 and 19 reduce it. + # https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8 + # Taxable income = 30,000 - 3,400 - 8,000 - 3,000 = 15,600; + # tax = 0.044 x 5,600 = 246.40; less a credit of 159 = 87.40. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + child1: + age: 3 + child2: + age: 5 + tax_units: + tax_unit: + members: [parent, child1, child2] + ms_cdcc: 159 + households: + household: + members: [parent, child1, child2] + state_code: MS + output: + household_state_tax_before_refundable_credits: 87.40 + household_refundable_state_tax_credits: 0 + +- name: Colorado household state tax includes the alternative minimum tax + # 2025 Form DR 0104: line 13 (tax) + line 14 (alternative minimum tax) + + # line 15 = line 16, before the refundable credits on line 31. + # https://tax.colorado.gov/sites/tax/files/documents/Book104_2025.pdf#page=11 + period: 2025 + input: + state_code: CO + co_income_tax_before_refundable_credits: 4_000 + co_amt: 500 + co_refundable_credits: 100 + output: + household_state_tax_before_refundable_credits: 4_500 + household_refundable_state_tax_credits: 100 diff --git a/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml b/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml index 36cc117ce9e..025b1eebd67 100644 --- a/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml +++ b/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml @@ -100,3 +100,20 @@ state_code: MI output: mi_surtax: 0 + +- name: Surtax reaches Michigan income tax, state income tax and the household total + period: 2027 + reforms: policyengine_us.reforms.states.mi.surtax.mi_surtax + input: + gov.contrib.states.mi.surtax.in_effect: True + mi_taxable_income: 600_000 + mi_income_tax_before_refundable_credits: 25_000 + mi_refundable_credits: 0 + filing_status: SINGLE + state_code: MI + output: + # (600_000 - 500_000) * 0.05 = 5_000; 25_000 + 5_000 = 30_000 + mi_surtax: 5_000 + mi_income_tax: 30_000 + state_income_tax: 30_000 + household_state_tax_before_refundable_credits: 30_000 diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py index 34faef8c568..fa7c302c106 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py @@ -8,8 +8,5 @@ class ms_income_tax(Variable): unit = USD definition_period = YEAR defined_for = StateCode.MS - - def formula(tax_unit, period, parameters): - tax_before_credits = tax_unit("ms_income_tax_before_credits_unit", period) - non_refundable_credits = tax_unit("ms_non_refundable_credits", period) - return max_(tax_before_credits - non_refundable_credits, 0) + adds = ["ms_income_tax_before_refundable_credits"] + subtracts = ["ms_refundable_credits"] diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py new file mode 100644 index 00000000000..30ac7cbed53 --- /dev/null +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class ms_income_tax_before_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "Mississippi income tax before refundable credits" + unit = USD + definition_period = YEAR + # Form 80-105 line 17 (total income tax due) less the credits on lines + # 18 and 19, which may not exceed it. + reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8" + defined_for = StateCode.MS + + def formula(tax_unit, period, parameters): + tax_before_credits = tax_unit("ms_income_tax_before_credits_unit", period) + non_refundable_credits = tax_unit("ms_non_refundable_credits", period) + return max_(tax_before_credits - non_refundable_credits, 0) diff --git a/policyengine_us/variables/gov/states/tax/income/state_income_tax.py b/policyengine_us/variables/gov/states/tax/income/state_income_tax.py index b4d2e47076b..0a4e46fd7ac 100644 --- a/policyengine_us/variables/gov/states/tax/income/state_income_tax.py +++ b/policyengine_us/variables/gov/states/tax/income/state_income_tax.py @@ -7,24 +7,8 @@ class state_income_tax(Variable): label = "state income tax" unit = USD definition_period = YEAR - - def formula(tax_unit, period, parameters): - income_tax_before_refundable_credits = add( - tax_unit, - period, - ["state_income_tax_before_refundable_credits"], - ) - refundable_credits = add(tax_unit, period, ["state_refundable_credits"]) - default_tax = income_tax_before_refundable_credits - refundable_credits - state_code = tax_unit.household("state_code", period) - return select( - [ - state_code == StateCode.WI, - state_code == StateCode.NJ, - ], - [ - tax_unit("wi_income_tax", period), - tax_unit("nj_income_tax", period), - ], - default=default_tax, - ) + # Household net income is built from these two totals, so a state's own + # final tax must equal them. State-specific elections belong in that + # state's components, not here. + adds = ["state_income_tax_before_refundable_credits"] + subtracts = ["state_refundable_credits"] diff --git a/policyengine_us/variables/household/income/household/household_state_income_tax.py b/policyengine_us/variables/household/income/household/household_state_income_tax.py index dc8fcb6e284..ce0b7da2ba6 100644 --- a/policyengine_us/variables/household/income/household/household_state_income_tax.py +++ b/policyengine_us/variables/household/income/household/household_state_income_tax.py @@ -2,28 +2,11 @@ class household_state_income_tax(Variable): - # This definition contains all modelled states, and exists to solve circular dependencies in state_income_tax. + # The same sum as state_income_tax, kept for callers that read this name. value_type = float entity = TaxUnit label = "household State tax" unit = USD definition_period = YEAR - - def formula(tax_unit, period, parameters): - default_tax = add( - tax_unit, - period, - ["state_income_tax_before_refundable_credits"], - ) - add(tax_unit, period, ["state_refundable_credits"]) - state_code = tax_unit.household("state_code", period) - return select( - [ - state_code == StateCode.WI, - state_code == StateCode.NJ, - ], - [ - tax_unit("wi_income_tax", period), - tax_unit("nj_income_tax", period), - ], - default=default_tax, - ) + adds = ["state_income_tax_before_refundable_credits"] + subtracts = ["state_refundable_credits"]