From ada4a1d5382c4a2f71b3dafa381fcf96d33ceab5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 4 Nov 2026 19:53:24 -0500 Subject: [PATCH 1/5] Count every taxed cash source in SPM market income (#9919) spm_unit_taxes subtracts the tax on every source of federal gross income, but person market_income, and so SPM resources, left out partnership and S corporation income, farm rental income, capital gain distributions reported without Schedule D, Form 4797 gains and losses, and employer-plan disability benefits. A family with partnership income lost the tax on it and never counted the income. Add each to market_income, with losses where the source has them, and add the last three to gov.household.market_income_sources so the person and household lists differ only by discharged debt. A new other_net_gain_person splits the tax unit's Form 4797 net amount between the head and spouse; other_net_gain_gross_income now takes its positive part, unchanged in value. The parity test now maps every source in gov.irs.gross_income.sources to the resource that counts its cash, or documents why the SPM leaves it out (discharged debt, Roth conversions, state and local tax refunds), and adds Hypothesis properties: a dollar of market income or loss moves both totals by a dollar, and cash income never lowers SPM net income for a family with no means-tested benefits to lose. Co-Authored-By: Claude Opus 5.5 --- ...arket-income-gross-income-sources.fixed.md | 1 + docs/spm.md | 27 ++++ .../gov/household/market_income_sources.yaml | 7 + .../household/household_market_income.yaml | 60 ++++++++ .../income/person/market_income.yaml | 83 ++++++++++ .../spm_unit/spm_unit_market_income.yaml | 35 +++++ .../income/spm_unit/spm_unit_net_income.yaml | 22 +++ .../test_household_spm_resource_parity.py | 144 +++++++++++++++++- .../other_net_gain_gross_income.py | 16 +- .../capital_gains/other_net_gain_person.py | 27 ++++ .../income/person/general/market_income.py | 16 +- 11 files changed, 417 insertions(+), 21 deletions(-) create mode 100644 changelog.d/spm-market-income-gross-income-sources.fixed.md create mode 100644 policyengine_us/variables/household/income/person/capital_gains/other_net_gain_person.py diff --git a/changelog.d/spm-market-income-gross-income-sources.fixed.md b/changelog.d/spm-market-income-gross-income-sources.fixed.md new file mode 100644 index 00000000000..343e2345a72 --- /dev/null +++ b/changelog.d/spm-market-income-gross-income-sources.fixed.md @@ -0,0 +1 @@ +Count partnership and S corporation income, farm rental income, capital gain distributions reported without Schedule D, Form 4797 gains and losses, and employer-plan disability benefits in person `market_income`, and so in SPM resources, since `spm_unit_taxes` already subtracted the tax on them (#9919). Household market income now also counts the last three, so the person and household lists match except for discharged debt. A new `other_net_gain_person` splits the tax unit's Form 4797 net gain or loss between the head and spouse. Programs whose income adds `market_income` (North Dakota's renter's refund, Washington's ECEAP and Missouri's Supplemental Nursing Care) and the ranking that picks which adults get a simulated marginal tax rate now see these sources too. diff --git a/docs/spm.md b/docs/spm.md index ea182d20972..edc3fa45a7a 100644 --- a/docs/spm.md +++ b/docs/spm.md @@ -10,6 +10,33 @@ aggregates count actual `housing_assistance`; only SPM resources count `spm_unit_capped_housing_subsidy`. The cap uses the canonical housing amount before the final model storage conversion. +## Cash income in resources + +SPM resources add person `market_income` and `spm_unit_benefits`, and +`spm_unit_taxes` subtracts the tax on every source of federal gross income. Each +source in `gov.irs.gross_income.sources` therefore needs a place in resources: +without one, the SPM takes a family's tax on the income and never counts the +income. `test_household_spm_resource_parity.py` maps each source to the variable +that counts its cash, and it fails when a new source has none. Market income +keeps a source's losses where the source has them, such as partnership, farm +rental and Form 4797 losses. + +Three sources stay out of resources on purpose: + +- `debt_relief`: discharged debt brings the family no cash. +- `taxable_roth_conversions`: a conversion moves savings from one retirement + account to another. +- `salt_refund_income`: the refund returns state or local tax from an earlier + year, and the SPM subtracts each year's tax as the family owes it. + +Census money income leaves out capital gains +([CPS subject definitions](https://www.census.gov/programs-surveys/cps/technical-documentation/subject-definitions.html#incomemeasurement)). +PolicyEngine's market income counts realized capital gains, and it counts +capital gain distributions and Form 4797 gains alongside them, so resources hold +every gain whose tax the SPM subtracts. Disability benefits from an employer plan +count in full, the part federal tax exempts included, since Census lists +disability benefits among the sources of money income. + ## Housing allocation and its assumptions The country sums modeled `housing_assistance` within each household, then diff --git a/policyengine_us/parameters/gov/household/market_income_sources.yaml b/policyengine_us/parameters/gov/household/market_income_sources.yaml index bbf4153f1ca..87d085309eb 100644 --- a/policyengine_us/parameters/gov/household/market_income_sources.yaml +++ b/policyengine_us/parameters/gov/household/market_income_sources.yaml @@ -9,6 +9,10 @@ values: - farm_operations_income - farm_rent_income - capital_gains + # Capital gain distributions reported without Schedule D. + - non_sch_d_capital_gains + # Form 4797 other gains and losses. + - other_net_gain_person - interest_income - rental_income # Royalties included in rental income @@ -16,6 +20,9 @@ values: - pension_income # Pension income includes annuities and endowment contracts - military_retirement_income + # Disability benefits from an employer plan, taxable or not. + - disability_benefits + # Discharged debt. person market_income, and so SPM resources, leave it out. - debt_relief # Income from estates and trusts (Schedule E Part III), including income # in respect of a decedent received through an estate or trust. diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml index b8d6f7b7fc3..64e46b3a26e 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml @@ -101,3 +101,63 @@ members: [parent, child] output: household_market_income: 75_000 + +- name: Partnership, S corporation and farm rental income are market income, with their signs. + period: 2026 + input: + people: + person: + partnership_income: 40_000 + s_corp_income: -5_000 + farm_rent_income: 3_000 + households: + household: + members: [person] + output: + household_market_income: 38_000 + +- name: Capital gain distributions reported without Schedule D are market income. + period: 2026 + input: + people: + person: + non_sch_d_capital_gains: 2_500 + households: + household: + members: [person] + output: + household_market_income: 2_500 + +- name: Employer disability benefits are market income, taxable or not. + period: 2026 + input: + people: + person: + disability_benefits: 9_000 + tax_exempt_disability_benefits: 9_000 + households: + household: + members: [person] + output: + taxable_disability_benefits: 0 + household_market_income: 9_000 + +- name: Form 4797 gains are market income for the household as a whole. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + spouse: + age: 48 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + other_net_gain: 8_000 + households: + household: + members: [head, spouse] + output: + household_market_income: 8_000 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml index c56256f6f54..d27093f57e1 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/market_income.yaml @@ -29,3 +29,86 @@ ak_permanent_fund_dividend: 1_000 market_income: 1_000 household_market_income: 1_000 + +- name: Partnership and S corporation income is market income, with its sign. + period: 2026 + input: + partnership_income: 50_000 + s_corp_income: -20_000 + output: + partnership_s_corp_income: 30_000 + market_income: 30_000 + +- name: Farm rental income is market income, with its sign. + period: 2026 + input: + employment_income: 30_000 + farm_rent_income: -4_000 + output: + market_income: 26_000 + +- name: Capital gain distributions reported without Schedule D are market income. + period: 2026 + input: + non_sch_d_capital_gains: 1_200 + output: + market_income: 1_200 + +- name: Employer disability benefits are market income, taxable or not. + period: 2026 + input: + disability_benefits: 12_000 + tax_exempt_disability_benefits: 5_000 + output: + taxable_disability_benefits: 7_000 + market_income: 12_000 + +- name: A Form 4797 loss is split between the head and spouse, and not the dependent. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + spouse: + age: 48 + is_tax_unit_spouse: true + child: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + other_net_gain: -6_000 + output: + other_net_gain_person: [-3_000, -3_000, 0] + other_net_gain_gross_income: [0, 0, 0] + market_income: [-3_000, -3_000, 0] + +- name: A Form 4797 gain is split between the head and spouse, and both parts are gross income. + period: 2026 + input: + people: + head: + age: 50 + is_tax_unit_head: true + spouse: + age: 48 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + other_net_gain: 10_000 + output: + other_net_gain_person: [5_000, 5_000] + other_net_gain_gross_income: [5_000, 5_000] + market_income: [5_000, 5_000] + +- name: A single filer's Form 4797 gain is all theirs. + period: 2026 + input: + other_net_gain: 7_500 + output: + other_net_gain_person: 7_500 + other_net_gain_gross_income: 7_500 + market_income: 7_500 diff --git a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml index 650d1aaff46..abff444e39f 100644 --- a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_market_income.yaml @@ -46,3 +46,38 @@ state_code: WA output: spm_unit_market_income: 0 + +- name: Partnership income and employer disability benefits reach SPM unit market income. + period: 2026 + input: + people: + parent: + age: 45 + partnership_income: 287_010 + grandparent: + age: 70 + disability_benefits: 6_000 + spm_units: + spm_unit: + members: [parent, grandparent] + households: + household: + members: [parent, grandparent] + state_code: TN + output: + spm_unit_market_income: 287_010 + 6_000 + household_market_income: 287_010 + 6_000 + +- name: A farm rental loss and capital gain distributions reach SPM unit market income. + period: 2026 + input: + people: + person: + age: 60 + farm_rent_income: -2_000 + non_sch_d_capital_gains: 3_500 + spm_units: + spm_unit: + members: [person] + output: + spm_unit_market_income: 1_500 diff --git a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_net_income.yaml b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_net_income.yaml index 392fbbe01ea..1938a04808f 100644 --- a/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/spm_unit/spm_unit_net_income.yaml @@ -52,3 +52,25 @@ spm_unit_spm_expenses: 0 output: spm_unit_net_income: 1 + 2 + 3 + 4 + 5 + 6 + +- name: Partnership income below the standard deduction is all SPM net income (#9919). + period: 2026 + input: + people: + person: + age: 40 + partnership_income: 10_000 + spm_units: + spm_unit: + members: [person] + takes_up_snap_if_eligible: false + households: + household: + members: [person] + state_code: TN + output: + spm_unit_market_income: 10_000 + spm_unit_benefits: 0 + spm_unit_taxes: 0 + spm_unit_spm_expenses: 0 + spm_unit_net_income: 10_000 diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index 901c44a04c7..f30e22b3240 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -4,13 +4,20 @@ cash. Each total keeps its own list of what it adds, in two different files, and the lists drift: a cash program reaches one total and not the other. -Three invariants hold for every input. +Five invariants hold for every input. 1. The list difference is exactly the documented one. Every program that one total counts and the other does not appears below with its reason. 2. A dollar of a cash payment adds a dollar to both totals. 3. For a one-person household, the two net incomes differ only by the SPM's subtractions and the items each measure defines differently. +4. Every source of federal gross income reaches SPM resources, or appears below + with the reason the SPM leaves it out. spm_unit_taxes subtracts the tax on + all of them, so a source missing from resources costs the family the tax + without the income (#9919). +5. A dollar of market income, or of a loss, moves both totals by a dollar, and + cash income never lowers the SPM net income of a family with no + means-tested benefits to lose. Child care stays out of this file: household_state_benefits and the SPM treat subsidies differently on purpose, and #9405 and #10569 cover that difference. @@ -66,14 +73,61 @@ "assigned_aca_ptc": "only under the flat tax reform's deduct_ptc switch", } -# Sources in the household list that person market_income still lacks. -# #9919 tracks these. +# Sources in the household list that person market_income leaves out on purpose. MARKET_HOUSEHOLD_ONLY = { - "debt_relief", - "farm_rent_income", - "partnership_s_corp_income", + "debt_relief": "discharged debt is not cash, and Census money income leaves it out", } +GROSS_INCOME_SOURCES = PACKAGE / "parameters/gov/irs/gross_income/sources.yaml" + +# Each source of federal gross income, and the market_income or +# spm_unit_benefits member that counts its cash in SPM resources. +GROSS_INCOME_IN_RESOURCES = { + "irs_employment_income": "employment_income", + "self_employment_income": "self_employment_income", + "sstb_self_employment_income": "sstb_self_employment_income", + "partnership_s_corp_income": "partnership_s_corp_income", + "farm_operations_income": "farm_operations_income", + "farm_rent_income": "farm_rent_income", + "other_net_gain_gross_income": "other_net_gain_person", + "capital_gains": "capital_gains", + "non_sch_d_capital_gains": "non_sch_d_capital_gains", + "taxable_interest_income": "interest_income", + "rental_income": "rental_income", + "dividend_income": "dividend_income", + "taxable_pension_income": "pension_income", + "taxable_disability_benefits": "disability_benefits", + "estate_income": "estate_income", + "taxable_unemployment_compensation": "total_unemployment_compensation", + "taxable_social_security": "social_security", + "illicit_income": "illicit_income", + "taxable_retirement_distributions": "retirement_distributions", + "miscellaneous_income": "miscellaneous_income", + "ak_permanent_fund_dividend": "ak_permanent_fund_dividend", + "taxable_alimony_income": "alimony_income", + "military_retirement_income": "military_retirement_income", +} + +# Sources of federal gross income that SPM resources leave out, and why. The +# SPM still subtracts the tax on them: the family owes it either way. +GROSS_INCOME_OUTSIDE_RESOURCES = { + "debt_relief": "discharged debt is not cash the family receives", + "taxable_roth_conversions": "moves savings between retirement accounts, " + "and is not cash available for spending", + "salt_refund_income": "returns state and local tax paid for an earlier " + "year, and the SPM subtracts each year's tax as owed, not as paid", +} + +# Market income inputs: the entity each sits on, and whether it can be a loss. +MARKET_SOURCES = [ + ("partnership_income", "person", True), + ("s_corp_income", "person", True), + ("farm_rent_income", "person", True), + ("non_sch_d_capital_gains", "person", False), + ("other_net_gain", "tax_unit", True), + ("disability_benefits", "person", False), +] + # Cash payments, with the state that pays each and a year it pays. CASH_PAYMENTS = [ ("ak_energy_relief", "person", "AK", 2024), @@ -136,12 +190,86 @@ def test_benefit_lists_differ_only_where_documented(year): assert spm_extra == set(), f"{year}: only in SPM: {spm_extra}" -def test_market_income_differs_only_by_the_tracked_sources(): +def test_market_income_differs_only_where_documented(): household = block_for("market_income_sources", 2026) - assert household - person_market_members() == MARKET_HOUSEHOLD_ONLY + assert household - person_market_members() == set(MARKET_HOUSEHOLD_ONLY) assert person_market_members() - household == set() +def gross_income_sources(): + """Every source in any year block of the federal gross income list.""" + raw = yaml.load(GROSS_INCOME_SOURCES.read_text(), Loader=yaml.BaseLoader) + return {name for block in raw["values"].values() for name in block} + + +def test_every_gross_income_source_reaches_spm_resources_or_is_documented(): + sources = gross_income_sources() + counted = set(GROSS_INCOME_IN_RESOURCES) + left_out = set(GROSS_INCOME_OUTSIDE_RESOURCES) + assert not counted & left_out + unmapped = sources - counted - left_out + assert unmapped == set(), ( + f"Gross income sources with no SPM treatment: {sorted(unmapped)}. Map " + "each to the resource that counts its cash, or document why the SPM " + "leaves it out." + ) + assert (counted | left_out) - sources == set(), "stale entries above" + + resources = person_market_members() | spm_members() + uncounted = { + source: resource + for source, resource in GROSS_INCOME_IN_RESOURCES.items() + if resource not in resources + } + assert uncounted == {}, f"mapped to a variable SPM resources lack: {uncounted}" + assert not left_out & resources + + +def market_situation(name, entity, amount, year=2026, state="TX"): + # Earnings this high rule out every means-tested response. + return one_person(state, 500_000, year, **{name: (entity, amount)}) + + +@settings(max_examples=25, deadline=None) +@given( + source=st.sampled_from(MARKET_SOURCES), + magnitude=st.integers(min_value=1, max_value=200_000), + loss=st.booleans(), +) +@example(source=MARKET_SOURCES[0], magnitude=287_010, loss=False) +@example(source=MARKET_SOURCES[0], magnitude=40_000, loss=True) +def test_market_income_moves_both_totals_dollar_for_dollar(source, magnitude, loss): + name, entity, can_be_loss = source + amount = -magnitude if loss and can_be_loss else magnitude + zero = Simulation(situation=market_situation(name, entity, 0)) + paid = Simulation(situation=market_situation(name, entity, amount)) + for names in (("spm_unit_market_income",), ("household_market_income",)): + change = total(paid, names, 2026) - total(zero, names, 2026) + assert change == pytest.approx(amount, abs=TOLERANCE), names + + +@settings(max_examples=25, deadline=None) +@given( + source=st.sampled_from(MARKET_SOURCES), + amount=st.integers(min_value=1, max_value=200_000), + state=st.sampled_from(STATES), +) +@example(source=MARKET_SOURCES[0], amount=287_010, state="TN") +def test_cash_income_never_lowers_spm_net_income(source, amount, state): + """The tax on a source never exceeds the source in SPM resources. + + Before #9919 the SPM subtracted the tax on partnership income without + counting the income, so this net change was negative. + """ + name, entity, _ = source + zero = Simulation(situation=market_situation(name, entity, 0, state=state)) + paid = Simulation(situation=market_situation(name, entity, amount, state=state)) + change = total(paid, ["spm_unit_net_income"], 2026) - total( + zero, ["spm_unit_net_income"], 2026 + ) + assert -TOLERANCE <= change <= amount + TOLERANCE + + def one_person(state, earnings, year, **variables): """A one-person household, with named inputs placed on their entities.""" groups = {"person": {}, "tax_unit": {}, "spm_unit": {}, "household": {}} diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/other_net_gain_gross_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/other_net_gain_gross_income.py index b5cfbea042e..f11c7107e92 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/other_net_gain_gross_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/other_net_gain_gross_income.py @@ -11,15 +11,7 @@ class other_net_gain_gross_income(Variable): reference = "https://www.irs.gov/instructions/i461" def formula(person, period, parameters): - tax_unit = person.tax_unit - # Avoid filing_status dependence (dependent_gross_income iterates over - # gross-income sources and filing_status ultimately depends on - # dependent_gross_income via head_of_household_eligible). Allocate the - # tax-unit-level other_net_gain across head + spouse equally. - is_head = person("is_tax_unit_head", period) - is_spouse = person("is_tax_unit_spouse", period) - is_head_or_spouse = is_head | is_spouse - num_head_or_spouse = tax_unit.sum(is_head_or_spouse) - share = where(num_head_or_spouse > 0, 1 / num_head_or_spouse, 0) - positive_other_net_gain = max_(0, tax_unit("other_net_gain", period)) - return is_head_or_spouse * positive_other_net_gain * share + # other_net_gain_person splits the tax unit's net amount equally + # between the head and spouse, so its positive part is each one's + # share of a positive tax-unit gain. + return max_(0, person("other_net_gain_person", period)) diff --git a/policyengine_us/variables/household/income/person/capital_gains/other_net_gain_person.py b/policyengine_us/variables/household/income/person/capital_gains/other_net_gain_person.py new file mode 100644 index 00000000000..a2bbada0c81 --- /dev/null +++ b/policyengine_us/variables/household/income/person/capital_gains/other_net_gain_person.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class other_net_gain_person(Variable): + value_type = float + entity = Person + label = "Other net gain or loss from Form 4797, per person" + unit = USD + documentation = ( + "The tax unit's Form 4797 other net gain or loss (other_net_gain), split " + "equally between the head and spouse, with its sign. Income measures add " + "it, and other_net_gain_gross_income takes its positive part." + ) + definition_period = YEAR + reference = "https://www.irs.gov/instructions/i4797" + + def formula(person, period, parameters): + tax_unit = person.tax_unit + # Head and spouse only, without filing_status: dependent_gross_income + # reads gross income through this variable, and filing_status depends + # on dependent_gross_income through head_of_household_eligible. + is_head_or_spouse = person("is_tax_unit_head", period) | person( + "is_tax_unit_spouse", period + ) + filers = tax_unit.sum(is_head_or_spouse) + share = is_head_or_spouse / max_(filers, 1) + return share * tax_unit("other_net_gain", period) diff --git a/policyengine_us/variables/household/income/person/general/market_income.py b/policyengine_us/variables/household/income/person/general/market_income.py index 35707b16cd4..225a1d8e406 100644 --- a/policyengine_us/variables/household/income/person/general/market_income.py +++ b/policyengine_us/variables/household/income/person/general/market_income.py @@ -6,7 +6,11 @@ class market_income(Variable): entity = Person label = "Market income" unit = USD - documentation = "Income from all non-government sources" + documentation = ( + "Income from all non-government sources. SPM resources add it, so it " + "counts every cash source of federal gross income that " + "spm_unit_benefits does not, with its losses where it has them." + ) definition_period = YEAR def formula(person, period, parameters): @@ -14,12 +18,20 @@ def formula(person, period, parameters): "employment_income", "self_employment_income", "sstb_self_employment_income", + # Schedule E partnership and S corporation income. + "partnership_s_corp_income", "pension_income", "dividend_income", "interest_income", "gi_cash_assistance", "capital_gains", + # Capital gain distributions reported without Schedule D. + "non_sch_d_capital_gains", + # Form 4797 other gains and losses. + "other_net_gain_person", "rental_income", + # Farm rental income (Form 4835). + "farm_rent_income", "estate_income", "illicit_income", "farm_operations_income", @@ -28,6 +40,8 @@ def formula(person, period, parameters): "strike_benefits", "retirement_distributions", "military_retirement_income", + # Disability benefits from an employer plan, taxable or not. + "disability_benefits", "ak_permanent_fund_dividend", ] return add(person, period, COMPONENTS) From 251ed5c113b23a9ea7d083f1176a07a7a2f365a7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 10:58:58 -0500 Subject: [PATCH 2/5] Leave federally excluded disability benefits out of ND renter's refund income NDCC 57-02-08.1(5)(c) counts income from all sources but excludes "any amount excluded from income by federal or state law". market_income now counts disability benefits in full, so subtract the part federal law excludes (IRC 104(a)(3)) from North Dakota's base, as the formula already does for pre-tax premiums. Partnership income, farm rent and capital gain distributions stay in the base, and new YAML cases pin both. Co-Authored-By: Claude Opus 5.5 --- .../nd_renters_refund_income.yaml | 24 +++++++++++++++++++ .../nd_renters_refund_income.py | 6 +++++ 2 files changed, 30 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index f7b07d2ac3c..187e04fe8d3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -225,3 +225,27 @@ output: # Medical costs paid: 10,000 + 6,400 = 16,400. nd_renters_refund_income: 13_600 + +- name: North Dakota counts partnership income and capital gain distributions from all sources (#9919). + period: 2025 + input: + state_code: ND + partnership_income: 20_000 + s_corp_income: -2_000 + farm_rent_income: 1_500 + non_sch_d_capital_gains: 500 + output: + # NDCC 57-02-08.1(5)(c): income for the year from all sources. + nd_renters_refund_income: 20_000 + +- name: North Dakota leaves out disability benefits that federal law excludes from income. + period: 2025 + input: + state_code: ND + employment_income: 10_000 + disability_benefits: 12_000 + tax_exempt_disability_benefits: 5_000 + output: + # The 5,000 from a plan the recipient paid for is excluded by IRC + # 104(a)(3), and NDCC 57-02-08.1(5)(c) excludes it: 10,000 + 7,000. + nd_renters_refund_income: 17_000 diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index 2330827d41a..6a85ef76629 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -32,6 +32,11 @@ def formula(tax_unit, period, parameters): # These premiums are disjoint from itemized_medical_expenses, # whose medical definition follows state income tax law. excluded_premiums = add(tax_unit, period, ["pre_tax_health_insurance_premiums"]) + # market_income counts disability benefits in full, and federal law + # excludes the part from a plan the recipient paid for (IRC 104(a)(3)). + excluded_disability_benefits = add( + tax_unit, period, ["disability_benefits"] + ) - add(tax_unit, period, ["taxable_disability_benefits"]) # Restore premiums excluded from the federal subtotal (computed, or # supplied with a caller's subtotal). medical_expenses = add( @@ -45,6 +50,7 @@ def formula(tax_unit, period, parameters): return max_( add(tax_unit, period, p.income_sources) - excluded_premiums + - excluded_disability_benefits - medical_expenses, 0, ) From 5a6ed8e1fa09af809b0dc45c4182e2f43d292150 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 10:59:48 -0500 Subject: [PATCH 3/5] Map dependent care benefits in the gross income coverage test Main added taxable_dependent_care_benefits to the federal gross income sources (#10579). employment_income already holds those benefits, because irs_employment_income subtracts them and the part above the IRC 129 exclusion returns through this source. Co-Authored-By: Claude Opus 5.5 --- changelog.d/spm-market-income-gross-income-sources.fixed.md | 2 +- policyengine_us/tests/test_household_spm_resource_parity.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog.d/spm-market-income-gross-income-sources.fixed.md b/changelog.d/spm-market-income-gross-income-sources.fixed.md index 343e2345a72..f039bbeb60a 100644 --- a/changelog.d/spm-market-income-gross-income-sources.fixed.md +++ b/changelog.d/spm-market-income-gross-income-sources.fixed.md @@ -1 +1 @@ -Count partnership and S corporation income, farm rental income, capital gain distributions reported without Schedule D, Form 4797 gains and losses, and employer-plan disability benefits in person `market_income`, and so in SPM resources, since `spm_unit_taxes` already subtracted the tax on them (#9919). Household market income now also counts the last three, so the person and household lists match except for discharged debt. A new `other_net_gain_person` splits the tax unit's Form 4797 net gain or loss between the head and spouse. Programs whose income adds `market_income` (North Dakota's renter's refund, Washington's ECEAP and Missouri's Supplemental Nursing Care) and the ranking that picks which adults get a simulated marginal tax rate now see these sources too. +Count partnership and S corporation income, farm rental income, capital gain distributions reported without Schedule D, Form 4797 gains and losses, and employer-plan disability benefits in person `market_income`, and so in SPM resources, since `spm_unit_taxes` already subtracted the tax on them (#9919). Household market income now also counts the last three, so the person and household lists match except for discharged debt. A new `other_net_gain_person` splits the tax unit's Form 4797 net gain or loss between the head and spouse. Programs whose income adds `market_income` (North Dakota's renter's refund, Washington's ECEAP and Missouri's Supplemental Nursing Care) and the ranking that picks which adults get a simulated marginal tax rate now see these sources too. North Dakota's renter's refund leaves out the disability benefits federal law excludes from income, as NDCC 57-02-08.1(5)(c) requires. diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index f30e22b3240..295de7501a2 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -84,6 +84,9 @@ # spm_unit_benefits member that counts its cash in SPM resources. GROSS_INCOME_IN_RESOURCES = { "irs_employment_income": "employment_income", + # employment_income holds these benefits: irs_employment_income subtracts + # them, and the part above the IRC 129 exclusion returns here. + "taxable_dependent_care_benefits": "employment_income", "self_employment_income": "self_employment_income", "sstb_self_employment_income": "sstb_self_employment_income", "partnership_s_corp_income": "partnership_s_corp_income", From 2fe939f6a0eac00eac42d2addfad9460fdc648af Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 11:17:52 -0500 Subject: [PATCH 4/5] Record North Dakota's insurance proceeds reading as a source conflict candidate Co-Authored-By: Claude Opus 5.5 --- source_conflicts/candidates.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index f254eb0fbd8..ebf3e22f829 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -6062,6 +6062,35 @@ candidates: and $105,227; 8, $107,574 and $107,565; 9, $109,914 and $109,903; 11, $114,582 and $114,580; 12, $116,922 and $116,919. Size 1 is below the ceiling, size 10 equals it, and from size 13 150% of the guideline exceeds the table. Verify each later fiscal-year table.' +- id: us-nd:renters-refund-insurance-proceeds + program: renter-credit + years: + from: 2025 + type: tax + question: Does the renter's refund count disability benefits from an employer-paid plan, when the Tax Commissioner's + guideline lists insurance policy proceeds for illness, injury or dismemberment among items that are not income? + sources: + - kind: statute + citation: N.D.C.C. 57-02-08.1(5)(c) + url: https://ndlegis.gov/cencode/t57c02.pdf#page=16 + - kind: guidance + citation: Property Tax Credits for North Dakota Homeowners and Renters, item 11 + url: https://www.tax.nd.gov/sites/www/files/documents/guidelines/homestead-veterans-renters/credits-for-nd-homeowners-renters-guideline.pdf#page=2 + readings: + statute: Income comes from all sources and leaves out any amount excluded from income by federal or state law. + Federal law excludes benefits from a plan the recipient paid for (IRC 104(a)(3)) and counts benefits from a plan + the employer paid for (IRC 105(a)). + other: Item 11 lists the proceeds as not income, whoever paid for the policy. + example: A renter with $10,000 of wages and $12,000 of disability benefits, $5,000 of them from a plan the renter + paid for, has income of $17,000 under the statute and $10,000 under item 11. + pe_us: + follows: statute + code: + - policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10663 + state: ruled + note: The statute's text settles the case, so it governs over the guideline's general list. - id: us-nd:stillborn-subtraction-dependency-condition program: income-tax years: From fc40c7a5d553e658319d3554c60b672946b42bd3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 5 Nov 2026 11:34:17 -0500 Subject: [PATCH 5/5] Test that the head and spouse hold a tax unit's whole Form 4797 amount Co-Authored-By: Claude Opus 5.5 --- .../test_household_spm_resource_parity.py | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index 295de7501a2..165d3b89f10 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -4,7 +4,7 @@ cash. Each total keeps its own list of what it adds, in two different files, and the lists drift: a cash program reaches one total and not the other. -Five invariants hold for every input. +Six invariants hold for every input. 1. The list difference is exactly the documented one. Every program that one total counts and the other does not appears below with its reason. @@ -18,6 +18,8 @@ 5. A dollar of market income, or of a loss, moves both totals by a dollar, and cash income never lowers the SPM net income of a family with no means-tested benefits to lose. +6. The head and spouse together hold a tax unit's whole Form 4797 amount, with + its sign, and gross income holds its positive part. Child care stays out of this file: household_state_benefits and the SPM treat subsidies differently on purpose, and #9405 and #10569 cover that difference. @@ -273,6 +275,52 @@ def test_cash_income_never_lowers_spm_net_income(source, amount, state): assert -TOLERANCE <= change <= amount + TOLERANCE +@settings(max_examples=10, deadline=None) +@given( + amount=st.integers(min_value=-200_000, max_value=200_000), + has_spouse=st.booleans(), + children=st.integers(min_value=0, max_value=2), +) +@example(amount=-6_000, has_spouse=True, children=1) +@example(amount=10_000, has_spouse=True, children=0) +def test_head_and_spouse_hold_the_whole_form_4797_amount(amount, has_spouse, children): + year = 2026 + people = {"head": {"age": {year: 45}, "is_tax_unit_head": {year: True}}} + adults = ["head"] + if has_spouse: + people["spouse"] = {"age": {year: 43}, "is_tax_unit_spouse": {year: True}} + adults.append("spouse") + kids = [f"child_{index}" for index in range(children)] + for kid in kids: + people[kid] = {"age": {year: 8}, "is_tax_unit_dependent": {year: True}} + members = adults + kids + simulation = Simulation( + situation={ + "people": people, + "tax_units": { + "tax_unit": {"members": members, "other_net_gain": {year: amount}} + }, + "spm_units": {"spm_unit": {"members": members}}, + "families": {"family": {"members": members}}, + "marital_units": { + "adults": {"members": adults}, + **{kid: {"members": [kid]} for kid in kids}, + }, + "households": { + "household": {"members": members, "state_name": {year: "TX"}} + }, + } + ) + shares = simulation.calculate("other_net_gain_person", year) + gross = simulation.calculate("other_net_gain_gross_income", year) + expected = [amount / len(adults)] * len(adults) + [0] * len(kids) + assert list(shares) == pytest.approx(expected, abs=TOLERANCE) + assert list(gross) == pytest.approx([max(0, x) for x in expected], abs=TOLERANCE) + assert total(simulation, ["spm_unit_market_income"], year) == pytest.approx( + amount, abs=TOLERANCE + ) + + def one_person(state, earnings, year, **variables): """A one-person household, with named inputs placed on their entities.""" groups = {"person": {}, "tax_unit": {}, "spm_unit": {}, "household": {}}