diff --git a/changelog.d/fix-dc-se-loss-addition-filers.fixed.md b/changelog.d/fix-dc-se-loss-addition-filers.fixed.md new file mode 100644 index 00000000000..472993f35fa --- /dev/null +++ b/changelog.d/fix-dc-se-loss-addition-filers.fixed.md @@ -0,0 +1 @@ +D.C.'s self-employment loss addition no longer counts a dependent's self-employment loss, either in the amount added back or in its split between spouses. diff --git a/policyengine_us/tests/core/test_dc_self_employment_loss_addition_invariants.py b/policyengine_us/tests/core/test_dc_self_employment_loss_addition_invariants.py new file mode 100644 index 00000000000..687a20c3a84 --- /dev/null +++ b/policyengine_us/tests/core/test_dc_self_employment_loss_addition_invariants.py @@ -0,0 +1,148 @@ +"""Invariants for the D.C. self-employment loss addition. + +The addition adds back the filers' self-employment losses deducted in federal +AGI above D.C.'s $12,000 threshold (Schedule I, Calculation A, line 6). A +tax-unit dependent's losses belong on the dependent's own return and are not in +`loss_ald`, so they must not enter the addition or its split between spouses. + +The properties must hold for every tax unit, so the tests draw a seeded random +population of D.C. tax units (single, head of household, joint, joint with +dependents). Filers have wages, and self-employment and rental income of +either sign; dependents have self-employment income of either sign. The tests +run it twice as vectorized simulations: as drawn, and with the dependents' +self-employment income set to zero. Losses stay far below the section 461(l) +limit, so every filer loss is deducted in federal AGI. + +1. Dependents' self-employment income never changes anyone's addition. +2. Dependents get no addition, and no addition is negative. +3. The tax unit's addition is the head's and spouse's self-employment losses + less $12,000, and never less than zero. A filer's rental loss and a + dependent's loss leave it unchanged. +4. A joint return splits the addition between the spouses in proportion to + their own losses. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +YEAR = 2025 +N_RANDOM = 200 +SEED = 20261006 +THRESHOLD = 12_000 +TOLERANCE = 0.02 # dollars; float32 sums + + +def _self_employment(rng, low, high): + return float(round(rng.choice([0.0, rng.uniform(low, 0), rng.uniform(0, high)]))) + + +def _situation(zero_dependents=False): + rng = np.random.default_rng(SEED) + people, tax_units, households = {}, {}, {} + for i in range(N_RANDOM): + kind = rng.choice( + ["single", "hoh", "joint", "joint_dependents"], p=[0.15, 0.2, 0.2, 0.45] + ) + roles = ["head", "spouse"] if kind.startswith("joint") else ["head"] + members = [] + for role in roles: + name = f"{role}_{i}" + members.append(name) + people[name] = { + "age": {YEAR: 45}, + "employment_income": {YEAR: float(round(rng.uniform(0, 150_000)))}, + "self_employment_income": { + YEAR: _self_employment(rng, -60_000, 40_000) + }, + "rental_income": {YEAR: _self_employment(rng, -40_000, 30_000)}, + f"is_tax_unit_{role}": {YEAR: True}, + } + if kind in ("hoh", "joint_dependents"): + for j in range(int(rng.integers(1, 3))): + name = f"child_{i}_{j}" + members.append(name) + amount = _self_employment(rng, -40_000, 20_000) + people[name] = { + "age": {YEAR: 16 + j}, + "is_tax_unit_dependent": {YEAR: True}, + "self_employment_income": { + YEAR: 0.0 if zero_dependents else amount + }, + } + tax_units[f"tu_{i}"] = {"members": members} + households[f"hh_{i}"] = {"members": members, "state_code": {YEAR: "DC"}} + return {"people": people, "tax_units": tax_units, "households": households} + + +@pytest.fixture(scope="module") +def run(): + out = {} + for name, zero_dependents in {"with": False, "without": True}.items(): + sim = Simulation(situation=_situation(zero_dependents=zero_dependents)) + out[name] = np.asarray( + sim.calculate("dc_self_employment_loss_addition", YEAR), dtype=float + ) + if name == "with": + for variable in [ + "total_self_employment_income", + "is_tax_unit_dependent", + "is_tax_unit_spouse", + ]: + out[variable] = np.asarray(sim.calculate(variable, YEAR)) + out["joint"] = ( + sim.calculate("filing_status", YEAR).decode_to_str() == "JOINT" + ) + out["person_tax_unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["person_tax_unit"], weights=values, minlength=len(run["joint"]) + ) + + +def test_population_has_dependent_losses_and_additions(run): + dependent = run["is_tax_unit_dependent"] + dependent_loss = _unit_sum( + run, dependent * (run["total_self_employment_income"] < 0) + ) + added = _unit_sum(run, run["with"]) > 0 + assert ((dependent_loss > 0) & added & run["joint"]).sum() > 5 + + +def test_dependents_income_never_changes_the_addition(run): + np.testing.assert_allclose(run["with"], run["without"], atol=TOLERANCE) + + +def test_dependents_get_nothing_and_nothing_is_negative(run): + assert np.all(run["with"][run["is_tax_unit_dependent"]] == 0) + assert np.all(run["with"] >= 0) + + +def _filer_loss(run): + loss = np.maximum(0, -run["total_self_employment_income"]) + return loss * ~run["is_tax_unit_dependent"] + + +def test_addition_is_filers_losses_over_threshold(run): + expected = np.maximum(0, _unit_sum(run, _filer_loss(run)) - THRESHOLD) + np.testing.assert_allclose(_unit_sum(run, run["with"]), expected, atol=TOLERANCE) + + +def test_joint_split_follows_each_spouses_loss(run): + filer_loss = _filer_loss(run) + unit = run["person_tax_unit"] + unit_loss = _unit_sum(run, filer_loss)[unit] + unit_addition = np.maximum(0, unit_loss - THRESHOLD) + share = np.divide( + filer_loss, unit_loss, out=np.zeros_like(filer_loss), where=unit_loss > 0 + ) + joint_filer = ~run["is_tax_unit_dependent"] & run["joint"][unit] + np.testing.assert_allclose( + run["with"][joint_filer], + (share * unit_addition)[joint_filer], + atol=TOLERANCE, + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml index 8ed948a57f6..8ff19166598 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml @@ -10,6 +10,91 @@ dc_self_employment_loss_addition: 8_000 dc_agi: 68_000 +- name: A dependent's self-employment loss does not dilute the head's addition. + absolute_error_margin: 0.01 + period: 2024 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + spouse: + age: 45 + employment_income: 40_000 + dependent: + age: 17 + is_tax_unit_dependent: true + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse, dependent] + households: + household: + members: [head, spouse, dependent] + state_code: DC + output: + # The dependent's loss is on their own return, so loss_ald holds only + # the head's $20,000. Addition: max(0, 20,000 - 12,000) = 8,000, all + # of it the head's. + dc_self_employment_loss_addition: [8_000, 0, 0] + +- name: A dependent's self-employment loss does not raise a head of household's addition. + absolute_error_margin: 0.01 + period: 2024 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -5_000 + rental_income: -30_000 + dependent: + age: 17 + is_tax_unit_dependent: true + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, dependent] + households: + household: + members: [head, dependent] + state_code: DC + output: + # The head's own self-employment loss is $5,000, under the $12,000 + # threshold. Counting the dependent's $20,000 would give + # min(25,000, 35,000 of business losses in loss_ald) - 12,000 = 13,000. + dc_self_employment_loss_addition: [0, 0] + +- name: Both spouses' losses share the addition; a dependent's loss is left out. + absolute_error_margin: 0.01 + period: 2024 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + spouse: + age: 45 + employment_income: 50_000 + self_employment_income: -10_000 + dependent: + age: 17 + is_tax_unit_dependent: true + self_employment_income: -15_000 + tax_units: + tax_unit: + members: [head, spouse, dependent] + households: + household: + members: [head, spouse, dependent] + state_code: DC + output: + # Addition: max(0, 30,000 - 12,000) = 18,000, split 20:10 by the + # spouses' own losses. + dc_self_employment_loss_addition: [12_000, 6_000, 0] + - name: Case 3, large SE loss capped by federal 461(l). absolute_error_margin: 0.01 period: 2024 diff --git a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py index 9348a9f1b1e..08bf9789851 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py @@ -14,18 +14,25 @@ class dc_self_employment_loss_addition(Variable): defined_for = StateCode.DC def formula(person, period, parameters): - loss_person = max_(0, -person("total_self_employment_income", period)) + # Only the head's and spouse's losses are on this return. A tax-unit + # dependent's losses are not in loss_ald, so counting them would raise + # the addition or dilute the filers' shares of it. + is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) + loss_person = is_head_or_spouse * max_( + 0, -person("total_self_employment_income", period) + ) loss_taxunit = person.tax_unit.sum(loss_person) - # Cap at SE loss actually deducted in federal AGI via loss_ald. - # loss_ald includes both SE and capital losses; isolate SE portion. + # Cap at the business losses actually deducted in federal AGI: loss_ald + # less its capital loss parts (self-employment, farm, rental, + # partnership, estate and other business losses after section 461(l)). loss_ald = person.tax_unit("loss_ald", period) capital_loss_in_ald = add( person.tax_unit, period, ["capital_losses_allowed_against_gains", "limited_capital_loss"], ) - se_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald) - effective_loss = min_(loss_taxunit, se_loss_in_ald) + business_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald) + effective_loss = min_(loss_taxunit, business_loss_in_ald) p = parameters(period).gov.states.dc.tax.income.additions addition_taxunit = max_(0, effective_loss - p.self_employment_loss.threshold) # allocate taxunit addition in proportion to head and spouse losses @@ -35,6 +42,4 @@ def formula(person, period, parameters): mask = loss_taxunit > 0 loss_fraction[mask] = loss_person[mask] / loss_taxunit[mask] addition_fraction = where(is_joint, loss_fraction, 1) - is_head = person("is_tax_unit_head", period) - is_spouse = person("is_tax_unit_spouse", period) - return (is_head | is_spouse) * addition_taxunit * addition_fraction + return is_head_or_spouse * addition_taxunit * addition_fraction