Skip to content
1 change: 1 addition & 0 deletions changelog.d/dependent-uc-state-agi.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading