diff --git a/changelog.d/dependent-uc-state-agi.fixed.md b/changelog.d/dependent-uc-state-agi.fixed.md new file mode 100644 index 00000000000..f6210a7a4fd --- /dev/null +++ b/changelog.d/dependent-uc-state-agi.fixed.md @@ -0,0 +1 @@ +Leave a tax unit dependent's unemployment compensation off the filers' California AGI subtractions, Rhode Island AGI additions and Wisconsin unemployment compensation subtraction, and a dependent's state tax refund off California's. diff --git a/policyengine_us/tests/core/test_dependent_unemployment_compensation_state_invariants.py b/policyengine_us/tests/core/test_dependent_unemployment_compensation_state_invariants.py new file mode 100644 index 00000000000..15d023e9283 --- /dev/null +++ b/policyengine_us/tests/core/test_dependent_unemployment_compensation_state_invariants.py @@ -0,0 +1,297 @@ +"""Invariants for a tax unit dependent's unemployment compensation in CA, RI and WI. + +`irs_gross_income` leaves out every tax unit dependent's income, which the +dependent reports on their own return. California's Schedule CA subtraction +(section B line 7), Rhode Island's add-back of compensation excluded from +federal AGI (RIGL 44-30-12(b)(6)) and Wisconsin's unemployment compensation +worksheet (Schedule SB line 3, line 1 "from line 7 of federal Schedule 1") +all adjust the filers' own federal AGI, so a dependent's compensation must +not move them. + +The tests draw a seeded random population of tax units, each single or +joint, with wages, unemployment compensation and state tax refunds for the +head and spouse, and zero to two dependent children with unemployment +compensation and, in California, state tax refunds. Every unit is placed +in each of the three states, and each scenario runs as one vectorized +simulation. TY2020 is the only year with a +federal exclusion (IRC 85(c)), so Rhode Island's add-back is non-zero only +then; TY2024 checks California and Wisconsin in a year without it. In 2020 +each person's taxable compensation is an input (their compensation less a +drawn excluded part), so the properties check the state lines whatever the +federal exclusion calculation gives. + +1. Each state line, and the state AGI built on it, is the same whether or + not the dependents have unemployment compensation. +2. Each line equals a reference built from the head's and spouse's own + amounts: California subtracts their taxable compensation and state tax + refunds; Rhode Island adds their federally excluded compensation; + Wisconsin runs its worksheet on their taxable compensation. +3. For tax units without dependents each line equals the sum over every + member, as before. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +SEED = 20261012 +TOLERANCE = 0.01 # dollars +STATES = ["CA", "RI", "WI"] +YEARS = [2020, 2024] +N_UNITS = 40 +EXCLUSION_YEAR = 2020 +EXCLUSION_CAP = 10_200 +# Wisconsin's worksheet base (line 3) by filing status: $18,000 joint, +# $12,000 otherwise (2024 Schedule SB instructions, page 2). No unit here is +# married filing separately. +WI_BASE_JOINT = 18_000 +WI_BASE_OTHER = 12_000 +WI_RATE = 0.5 + + +def _draw_units(n, rng): + def draw(scale): + return float(round(rng.choice([0.0, rng.uniform(0, scale)]))) + + def excluded(compensation): + # The part of 2020 compensation excluded from federal AGI: none, all + # up to the cap, or part of it. + share = float(rng.choice([0.0, 1.0, 0.5])) + return float(round(min(compensation, EXCLUSION_CAP) * share)) + + units = [] + for _ in range(n): + joint = bool(rng.random() < 0.5) + # Low wages in some units keep Wisconsin's worksheet in its + # phase-out range, where line 7 is below twice the compensation. + wage_scale = float(rng.choice([20_000, 90_000])) + + def filer(): + compensation = draw(15_000) + return { + "employment_income": draw(wage_scale), + "unemployment_compensation": compensation, + "salt_refund_income": draw(1_500), + "excluded_2020": excluded(compensation), + } + + def dependent(): + compensation = draw(12_000) + return { + "age": int(rng.integers(5, 19)), + "unemployment_compensation": compensation, + "excluded_2020": excluded(compensation), + "salt_refund_income": draw(800), + } + + units.append( + { + "joint": joint, + "head": filer(), + "spouse": filer() if joint else None, + "dependents": [dependent() for _ in range(int(rng.integers(0, 3)))], + } + ) + return units + + +def _compensation_inputs(person, year, scale=1.0): + compensation = scale * person["unemployment_compensation"] + inputs = {"unemployment_compensation": {year: compensation}} + if year == EXCLUSION_YEAR: + taxable = compensation - scale * person["excluded_2020"] + inputs["taxable_unemployment_compensation"] = {year: taxable} + return inputs + + +def _filer_inputs(filer, state, year): + # Only California subtracts state tax refunds among these lines; + # Wisconsin's worksheet counts them on line 5, which the model leaves out, + # so the refunds go only to the California units. + refunds = filer["salt_refund_income"] if state == "CA" else 0.0 + return { + "employment_income": {year: filer["employment_income"]}, + "salt_refund_income": {year: refunds}, + **_compensation_inputs(filer, year), + } + + +def _situation(units, year, *, zero_dependents=False): + people, tax_units, marital_units, spm_units = {}, {}, {}, {} + families, households = {}, {} + for state in STATES: + for i, u in enumerate(units): + key = f"{state}_{i}" + head = f"head_{key}" + members = [head] + couple = [head] + people[head] = { + "age": {year: 40}, + "is_tax_unit_head": {year: True}, + "is_tax_unit_spouse": {year: False}, + "is_tax_unit_dependent": {year: False}, + **_filer_inputs(u["head"], state, year), + } + if u["spouse"] is not None: + spouse = f"spouse_{key}" + members.append(spouse) + couple.append(spouse) + people[spouse] = { + "age": {year: 38}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: True}, + "is_tax_unit_dependent": {year: False}, + **_filer_inputs(u["spouse"], state, year), + } + marital_units[f"mu_{key}"] = {"members": couple} + scale = 0.0 if zero_dependents else 1.0 + for j, dependent in enumerate(u["dependents"]): + child = f"child_{key}_{j}" + members.append(child) + marital_units[f"mu_{key}_{j}"] = {"members": [child]} + people[child] = { + "age": {year: dependent["age"]}, + "is_tax_unit_head": {year: False}, + "is_tax_unit_spouse": {year: False}, + "is_tax_unit_dependent": {year: True}, + **_compensation_inputs(dependent, year, scale=scale), + # Wisconsin's general subtractions still take a + # dependent's refund (a separate fix), so only the + # California dependents get one. + "salt_refund_income": { + year: scale * dependent["salt_refund_income"] * (state == "CA") + }, + } + tax_units[f"tu_{key}"] = {"members": members} + spm_units[f"spm_{key}"] = {"members": members} + families[f"fam_{key}"] = {"members": members} + households[f"hh_{key}"] = { + "members": members, + "state_name": {year: state}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "families": families, + "households": households, + } + + +def _state_slice(state): + start = STATES.index(state) * N_UNITS + return slice(start, start + N_UNITS) + + +def _filer_sum(sim, variable, year): + """Sum a person-level variable over each tax unit's head and spouse.""" + values = sim.calculate(variable, year) + filer = ~sim.calculate("is_tax_unit_dependent", year) + tax_unit = sim.populations["tax_unit"] + return tax_unit.sum(values * filer) + + +def _member_sum(sim, variable, year): + values = sim.calculate(variable, year) + return sim.populations["tax_unit"].sum(values) + + +LINES = { + "CA": ("ca_agi_subtractions", "ca_agi"), + "RI": ("ri_additions", "ri_agi"), + "WI": ("wi_unemployment_compensation_subtraction", "wi_agi"), +} + + +@pytest.fixture(scope="module", params=YEARS) +def simulations(request): + year = request.param + rng = np.random.default_rng(SEED + year) + units = _draw_units(N_UNITS, rng) + with_income = Simulation(situation=_situation(units, year)) + without_income = Simulation(situation=_situation(units, year, zero_dependents=True)) + return year, units, with_income, without_income + + +def test_dependents_compensation_leaves_state_lines_unchanged(simulations): + year, units, with_income, without_income = simulations + # The population must exercise the property: some dependents have + # compensation in every state, and some California dependents refunds. + assert any(d["unemployment_compensation"] for u in units for d in u["dependents"]) + assert any(d["salt_refund_income"] for u in units for d in u["dependents"]) + for state, variables in LINES.items(): + rows = _state_slice(state) + for variable in variables: + np.testing.assert_allclose( + with_income.calculate(variable, year)[rows], + without_income.calculate(variable, year)[rows], + atol=TOLERANCE, + err_msg=f"{state} {variable} {year}", + ) + + +def test_state_lines_use_the_filers_own_amounts(simulations): + year, units, sim, _ = simulations + filer_taxable = _filer_sum(sim, "taxable_unemployment_compensation", year) + filer_excluded = _filer_sum(sim, "tax_exempt_unemployment_compensation", year) + filer_refunds = _filer_sum(sim, "salt_refund_income", year) + + ca = _state_slice("CA") + np.testing.assert_allclose( + sim.calculate("ca_agi_subtractions", year)[ca], + (filer_taxable + filer_refunds)[ca], + atol=TOLERANCE, + ) + + ri = _state_slice("RI") + np.testing.assert_allclose( + sim.calculate("ri_additions", year)[ri], + filer_excluded[ri], + atol=TOLERANCE, + ) + if year == 2020: + # The units draw enough compensation that the 2020 exclusion bites. + assert filer_excluded[ri].sum() > 0 + + wi = _state_slice("WI") + joint = np.array([u["joint"] for u in units]) + base = np.where(joint, WI_BASE_JOINT, WI_BASE_OTHER) + agi = sim.calculate("adjusted_gross_income", year)[wi] + taxable_social_security = sim.calculate("tax_unit_taxable_social_security", year)[ + wi + ] + line_1 = filer_taxable[wi] + line_8 = np.maximum(0, agi - base - taxable_social_security) * WI_RATE + expected = line_1 - np.minimum(line_1, line_8) + np.testing.assert_allclose( + sim.calculate("wi_unemployment_compensation_subtraction", year)[wi], + expected, + atol=TOLERANCE, + ) + # Some units fall in the phase-out range, where the worksheet keeps part + # of the compensation taxable. + assert ((expected > 0) & (expected < line_1)).any() + + +def test_units_without_dependents_match_the_sum_over_members(simulations): + year, units, sim, _ = simulations + no_dependents = np.array([not u["dependents"] for u in units]) + ca = _state_slice("CA") + member_ca = ( + _member_sum(sim, "taxable_unemployment_compensation", year) + + _member_sum(sim, "salt_refund_income", year) + )[ca] + np.testing.assert_allclose( + sim.calculate("ca_agi_subtractions", year)[ca][no_dependents], + member_ca[no_dependents], + atol=TOLERANCE, + ) + ri = _state_slice("RI") + member_ri = _member_sum(sim, "tax_exempt_unemployment_compensation", year)[ri] + np.testing.assert_allclose( + sim.calculate("ri_additions", year)[ri][no_dependents], + member_ri[no_dependents], + atol=TOLERANCE, + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_agi_subtractions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_agi_subtractions.yaml new file mode 100644 index 00000000000..dcff23ce1e3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/ca_agi_subtractions.yaml @@ -0,0 +1,92 @@ +# Schedule CA (540) column B subtracts amounts from column A, which holds the +# amounts on the filers' own federal return (2024 Schedule CA (540) +# instructions, "Column A - Federal Amounts"). Federal AGI leaves out a tax +# unit dependent's income, which the dependent reports on their own return, +# so only the head's and spouse's amounts are subtracted. + +- name: Case 1, a dependent's unemployment compensation is not subtracted from the filers' CA AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 90_000 + person2: + age: 43 + employment_income: 20_000 + person3: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + output: + # The child's compensation is taxable on the child's own federal return. + taxable_unemployment_compensation: [0, 0, 10_000] + adjusted_gross_income: 110_000 + ca_agi_subtractions: 0 + ca_agi: 110_000 + +- name: Case 2, the head's unemployment compensation is subtracted; the dependent's is not. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + employment_income: 90_000 + unemployment_compensation: 8_000 + person2: + age: 43 + employment_income: 20_000 + person3: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + output: + # Federal AGI: 90,000 + 20,000 wages + the head's 8,000 compensation + # (federal Schedule 1, line 7). + adjusted_gross_income: 118_000 + # Schedule CA (540), section B, line 7, column B: 8,000. + ca_agi_subtractions: 8_000 + ca_agi: 110_000 + +- name: Case 3, a dependent's state tax refund is not subtracted from the filer's CA AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 50_000 + salt_refund_income: 600 + person2: + age: 19 + is_tax_unit_dependent: true + is_full_time_student: true + salt_refund_income: 400 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: CA + output: + adjusted_gross_income: 50_600 + # Schedule CA (540), section B, line 1, column B: the head's 600 only. + ca_agi_subtractions: 600 + ca_agi: 50_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/additions/ri_additions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/additions/ri_additions.yaml new file mode 100644 index 00000000000..8eb5cce9bb2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ri/tax/income/agi/additions/ri_additions.yaml @@ -0,0 +1,79 @@ +# RIGL 44-30-12(b)(6) adds back the unemployment compensation excluded from +# the filers' federal AGI under IRC 85(c) for 2020. Federal AGI leaves out a +# tax unit dependent's income, which the dependent reports on their own +# return, so a dependent's excluded compensation is not added to the filers' +# RI AGI. + +- name: Case 1, a dependent's excluded 2020 unemployment compensation is not added to the filer's RI AGI. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 40_000 + person2: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: RI + output: + # The child's 10,000 is under the 10,200 exclusion on the child's own + # federal return. + tax_exempt_unemployment_compensation: [0, 10_000] + adjusted_gross_income: 40_000 + ri_additions: 0 + ri_agi: 40_000 + +- name: Case 2, the head's excluded unemployment compensation is added back; the dependent's is not. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 40_000 + tax_exempt_unemployment_compensation: 8_000 + person2: + age: 17 + is_tax_unit_dependent: true + tax_exempt_unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: RI + output: + ri_additions: 8_000 + +- name: Case 3, a married couple's excluded unemployment compensation is added back for each spouse. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + tax_exempt_unemployment_compensation: 6_000 + person2: + age: 38 + tax_exempt_unemployment_compensation: 4_000 + person3: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: RI + output: + ri_additions: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_unemployment_compensation_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_unemployment_compensation_subtraction.yaml index ff13ab35a82..133a96f3efa 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_unemployment_compensation_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_unemployment_compensation_subtraction.yaml @@ -39,3 +39,60 @@ state_code: WI output: wi_unemployment_compensation_subtraction: 0 + +# Worksheet line 1 takes the unemployment compensation from line 7 of the +# filers' federal Schedule 1 (2024 Schedule SB instructions, line 3). Federal +# AGI leaves out a tax unit dependent's compensation, which the dependent +# reports on their own return. + +- name: Case 5, a dependent's unemployment compensation is not subtracted from the filer's income. + absolute_error_margin: 0.01 + period: 2024 + input: + people: + person1: + age: 35 + employment_income: 15_000 + person2: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: WI + output: + taxable_unemployment_compensation: [0, 10_000] + adjusted_gross_income: 15_000 + wi_unemployment_compensation_subtraction: 0 + wi_agi: 15_000 + +- name: Case 6, the head's unemployment compensation goes through the worksheet; the dependent's does not. + absolute_error_margin: 0.01 + period: 2024 + input: + people: + person1: + age: 35 + employment_income: 12_000 + unemployment_compensation: 2_000 + person2: + age: 17 + is_tax_unit_dependent: true + unemployment_compensation: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: WI + output: + # Worksheet: line 1 = 2,000; line 2 (federal AGI) = 14,000; line 3 = + # 12,000 (box D); line 7 = 14,000 - 12,000 = 2,000; line 8 = 1,000; + # line 9 = min(2,000, 1,000) = 1,000; line 10 = 2,000 - 1,000 = 1,000. + adjusted_gross_income: 14_000 + wi_unemployment_compensation_subtraction: 1_000 diff --git a/policyengine_us/variables/gov/states/ca/tax/income/ca_agi_subtractions.py b/policyengine_us/variables/gov/states/ca/tax/income/ca_agi_subtractions.py index f1500e86a22..350c323f079 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/ca_agi_subtractions.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/ca_agi_subtractions.py @@ -10,11 +10,21 @@ class ca_agi_subtractions(Variable): reference = ( "https://www.ftb.ca.gov/forms/2021/2021-540-ca-instructions.html", "https://www.ftb.ca.gov/forms/2022/2022-540-ca-instructions.html", + # Column A takes the amounts on the filers' own federal return. + "https://www.ftb.ca.gov/forms/2024/2024-540-ca-instructions.html", + # PDF pages 4, 13: a dependent's own filing requirement and + # standard deduction worksheet. + "https://www.ftb.ca.gov/forms/2024/2024-540-booklet.pdf#page=4", ) defined_for = StateCode.CA def formula(tax_unit, period, parameters): p = parameters(period).gov.states.ca.tax.income.agi - total_subtractions = add(tax_unit, period, p.subtractions) + # Each subtraction takes an amount from the filers' federal return. + # Federal AGI leaves out a tax unit dependent's income, which the + # dependent reports on their own return, so person-level amounts + # count only for the head and spouse. Tax-unit-level items, such as + # us_govt_interest, are added as they are. + total_subtractions = tax_unit_non_dep_add(tax_unit, period, p.subtractions) # Prevent negative subtractions from acting as additions return max_(0, total_subtractions) diff --git a/policyengine_us/variables/gov/states/ri/tax/income/agi/ri_additions.py b/policyengine_us/variables/gov/states/ri/tax/income/agi/ri_additions.py index 50a237ec877..88802833c21 100644 --- a/policyengine_us/variables/gov/states/ri/tax/income/agi/ri_additions.py +++ b/policyengine_us/variables/gov/states/ri/tax/income/agi/ri_additions.py @@ -7,6 +7,21 @@ class ri_additions(Variable): label = "Rhode Island AGI Additions" unit = USD definition_period = YEAR - reference = "https://tax.ri.gov/sites/g/files/xkgbur541/files/2022-12/2022%201041%20Schedule%20M_w.pdf#page=2" + reference = ( + "https://tax.ri.gov/sites/g/files/xkgbur541/files/2022-12/2022%20RI%20Schedule%20M_w.pdf#page=2", + # R.I. Gen. Laws ยง 44-30-12(a) and (b)(6) + "https://webserver.rilegislature.gov/Statutes/TITLE44/44-30/44-II/44-30-12.htm", + # 2020 RI Schedule M line 2g + "https://tax.ri.gov/sites/g/files/xkgbur541/files/forms/2020/Income/2020-RI-Schedule-M_w.pdf#page=2", + # PDF pages 1, 9: who must file; Schedule M line 2g + "https://tax.ri.gov/sites/g/files/xkgbur541/files/forms/2020/Income/2020-Resident-Instructions_03282021.pdf#page=1", + ) defined_for = StateCode.RI - adds = "gov.states.ri.tax.income.agi.additions.additions" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.ri.tax.income.agi.additions + # The additions modify the filers' federal AGI. A tax unit + # dependent's income, including their federally excluded + # unemployment compensation, is on their own return, so person-level + # additions count only the head and spouse. + return tax_unit_non_dep_add(tax_unit, period, p.additions) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_unemployment_compensation_subtraction.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_unemployment_compensation_subtraction.py index 051d59a80cf..643b0078853 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_unemployment_compensation_subtraction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_unemployment_compensation_subtraction.py @@ -8,13 +8,22 @@ class wi_unemployment_compensation_subtraction(Variable): unit = USD definition_period = YEAR reference = ( - "https://www.revenue.wi.gov/TaxForms2021/2021-ScheduleSB-inst.pdf#page=2", - "https://www.revenue.wi.gov/TaxForms2022/2022-ScheduleSB-Inst.pdf#page=1", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/8", + "https://www.revenue.wi.gov/TaxForms2021/2021-ScheduleSB-inst.pdf#page=3", + "https://www.revenue.wi.gov/TaxForms2022/2022-ScheduleSB-Inst.pdf#page=2", + "https://www.revenue.wi.gov/TaxForms2024/2024-ScheduleSB-Inst.pdf#page=2", + # A dependent's own filing requirement + "https://www.revenue.wi.gov/TaxForms2024/2024-Form1-Inst.pdf#page=3", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): - us_tuc = add(tax_unit, period, ["taxable_unemployment_compensation"]) + # Worksheet line 1 is the unemployment compensation in the filers' + # federal AGI, which leaves out a tax unit dependent's; the dependent + # reports their own on their own return. + us_tuc = tax_unit_non_dep_add( + tax_unit, period, ["taxable_unemployment_compensation"] + ) us_agi = tax_unit("adjusted_gross_income", period) wi = parameters(period).gov.states.wi.tax.income p = wi.subtractions.unemployment_compensation.income_phase_out