diff --git a/changelog.d/nd-renters-refund-federal-exclusions.fixed.md b/changelog.d/nd-renters-refund-federal-exclusions.fixed.md new file mode 100644 index 00000000000..42ede54dde3 --- /dev/null +++ b/changelog.d/nd-renters-refund-federal-exclusions.fixed.md @@ -0,0 +1 @@ +North Dakota renter's refund income leaves out every part of market income that federal law excludes from gross income, as N.D.C.C. 57-02-08.1(5)(c) requires: tax-exempt interest and savings bond interest spent on higher education, the nontaxable parts of pensions and retirement distributions, the payroll deductions and employer adoption assistance excluded from wages, income from Puerto Rico and the U.S. possessions that federal law excludes, and alimony under divorce instruments executed after 2018, besides the disability benefits it already left out. diff --git a/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/excluded_deductions.yaml b/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/excluded_deductions.yaml new file mode 100644 index 00000000000..cf7327a3942 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/excluded_deductions.yaml @@ -0,0 +1,27 @@ +description: North Dakota subtracts these federal exclusions from income under the Renter's Refund program, because the model deducts them above the line and market income still holds the income they exclude. +values: + 2025-01-01: + # Savings bond interest spent on higher education. + - us_bonds_for_higher_ed + # Employer adoption assistance. + - qualified_adoption_assistance_expense + # Income of bona fide residents of Guam, American Samoa or the Northern + # Mariana Islands from sources there. + - specified_possession_income + # Income of bona fide residents of Puerto Rico from sources there. + - puerto_rico_income +metadata: + unit: list + period: year + label: North Dakota Renter's Refund federal exclusions deducted above the line + reference: + - title: N.D.C.C. § 57-02-08.1(5)(c) + href: https://ndlegis.gov/cencode/t57c02.pdf#page=16 + - title: 26 U.S. Code § 135(a) + href: https://www.law.cornell.edu/uscode/text/26/135#a + - title: 26 U.S. Code § 137(a) + href: https://www.law.cornell.edu/uscode/text/26/137#a + - title: 26 U.S. Code § 931(a) + href: https://www.law.cornell.edu/uscode/text/26/931#a + - title: 26 U.S. Code § 933 + href: https://www.law.cornell.edu/uscode/text/26/933 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.yaml new file mode 100644 index 00000000000..9faa2a55b46 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.yaml @@ -0,0 +1,76 @@ +- name: Each part of market income that federal law excludes is subtracted once. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 60_000 + traditional_401k_contributions: 4_000 + traditional_403b_contributions: 1_000 + pre_tax_health_insurance_premiums: 3_000 + health_savings_account_payroll_contributions: 2_000 + dependent_care_employer_benefits: 5_000 + taxable_interest_income: 700 + tax_exempt_interest_income: 1_500 + taxable_private_pension_income: 9_000 + tax_exempt_private_pension_income: 2_500 + tax_exempt_401k_distributions: 3_000 + taxable_401k_distributions: 4_000 + disability_benefits: 8_000 + tax_exempt_disability_benefits: 3_500 + alimony_income: 6_000 + divorce_year: 2021 + output: + # Payroll deductions 4,000 + 1,000 + 3,000 + 2,000 + 5,000 = 15,000, + # all within their limits, plus 1,500 + 2,500 + 3,000 + 3,500 + 6,000. + nd_renters_refund_federally_excluded_income: 31_500 + +- name: Taxable amounts are not excluded. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 30_000 + taxable_interest_income: 1_000 + taxable_private_pension_income: 5_000 + taxable_ira_distributions: 2_000 + disability_benefits: 4_000 + alimony_income: 6_000 + divorce_year: 2015 + strike_benefits: 1_000 + military_retirement_pay: 10_000 + output: + nd_renters_refund_federally_excluded_income: 0 + +- name: A tax-exempt amount above the reported total of its source is capped at that total. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + interest_income: 3_000 + tax_exempt_interest_income: 5_000 + output: + market_income: 3_000 + nd_renters_refund_federally_excluded_income: 3_000 + +- name: A source reported only as a total counts in full. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + pension_income: 12_000 + output: + nd_renters_refund_federally_excluded_income: 0 + +- name: The exclusion applies only in North Dakota. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: SD + tax_exempt_interest_income: 5_000 + output: + nd_renters_refund_federally_excluded_income: 0 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 187e04fe8d3..92a0bc58635 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 @@ -249,3 +249,234 @@ # 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 + +- name: North Dakota leaves out interest on state and local bonds, which federal law excludes from income. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 20_000 + taxable_interest_income: 1_000 + tax_exempt_interest_income: 5_000 + output: + market_income: 26_000 + # IRC 103(a) excludes the 5,000 of bond interest, and NDCC + # 57-02-08.1(5)(c) excludes any amount federal law excludes: + # 20,000 + 1,000. + nd_renters_refund_income: 21_000 + +- name: North Dakota counts social security in full but leaves out the nontaxable parts of pensions and IRA distributions. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + taxable_private_pension_income: 10_000 + # The investment in the contract (IRC 72(b)(1)). + tax_exempt_private_pension_income: 4_000 + # A qualified Roth IRA distribution (IRC 408A(d)(1)). + tax_exempt_ira_distributions: 6_000 + taxable_ira_distributions: 3_000 + social_security_retirement: 8_000 + output: + # Social security is the one federal exclusion the statute keeps in + # income: 10,000 + 3,000 + 8,000. + nd_renters_refund_income: 21_000 + +- name: North Dakota leaves out the payroll deductions federal law excludes from wages. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 50_000 + # IRC 402(e)(3) and 402(g)(1) elective deferrals, and HSA contributions + # through payroll (IRC 106(d)). + traditional_401k_contributions: 5_000 + health_savings_account_payroll_contributions: 2_000 + output: + irs_employment_income: 43_000 + nd_renters_refund_income: 43_000 + +- name: North Dakota counts dependent care assistance above the IRC 129 limit. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 50_000 + dependent_care_employer_benefits: 9_000 + output: + # IRC 129(a)(2)(A) excludes 7,500 for 2026, and the other 1,500 is + # gross income: 50,000 - 7,500. + taxable_dependent_care_benefits: 1_500 + nd_renters_refund_income: 42_500 + +- name: North Dakota leaves out alimony under a divorce instrument executed after 2018. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 10_000 + alimony_income: 12_000 + divorce_year: 2020 + output: + # The recipient's gross income leaves out alimony under an instrument + # executed after 2018 (IRS Topic 452). + nd_renters_refund_income: 10_000 + +- name: North Dakota counts alimony under a divorce instrument executed before 2019. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 10_000 + alimony_income: 12_000 + divorce_year: 2015 + output: + nd_renters_refund_income: 22_000 + +- name: North Dakota leaves out a dependent's tax-exempt interest and counts the rest of the dependent's income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 30_000 + child: + age: 15 + is_tax_unit_dependent: true + taxable_interest_income: 500 + tax_exempt_interest_income: 2_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: ND + output: + # NDCC 57-02-08.1(5)(c) counts the dependent's income and excludes what + # federal law excludes: 30,000 + 500. + nd_renters_refund_income: 30_500 + +- name: North Dakota leaves out savings bond interest spent on higher education, which federal law excludes from income. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + employment_income: 20_000 + taxable_interest_income: 3_000 + # IRC 135(a) excludes it. The model deducts it above the line, so + # interest_income still holds it. + us_bonds_for_higher_ed: 2_000 + output: + market_income: 23_000 + # 20,000 + 3,000 - 2,000. + nd_renters_refund_income: 21_000 + +- name: North Dakota leaves out savings bond interest whose exclusion the household records on the student it paid for. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + employment_income: 30_000 + taxable_interest_income: 4_000 + child: + age: 19 + is_tax_unit_dependent: true + us_bonds_for_higher_ed: 2_500 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: ND + output: + # 30,000 + 4,000 - 2,500. + nd_renters_refund_income: 31_500 + +- name: North Dakota leaves out employer adoption assistance, which federal law excludes from income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 45_000 + child: + age: 3 + is_tax_unit_dependent: true + # IRC 137(a) excludes it, and households record it on the adopted + # child. The model deducts it above the line, so the wages include it. + qualified_adoption_assistance_expense: 5_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: ND + output: + # 45,000 - 5,000. + nd_renters_refund_income: 40_000 + +- name: North Dakota leaves out income from Puerto Rico that federal law excludes. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + tax_units: + tax_unit: + members: [person] + # IRC 933 excludes it. The model deducts it above the line, so the + # wages include it. + puerto_rico_income: 15_000 + households: + household: + members: [person] + state_code: ND + output: + # 40,000 - 15,000. + nd_renters_refund_income: 25_000 + +- name: North Dakota takes the exclusions the model deducts above the line out of market income only, never out of social security. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + state_code: ND + taxable_interest_income: 1_000 + us_bonds_for_higher_ed: 3_000 + social_security_retirement: 8_000 + output: + # Only the 1,000 of market income can leave: 1,000 + 8,000 - 1,000. + nd_renters_refund_income: 8_000 + +- name: North Dakota's income tax reductions leave retired military benefits, capital gains and dividends in income. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + state_code: ND + # N.D.C.C. 57-38-30.3(2)(d) and (r) reduce North Dakota taxable income by + # 40 percent of net long-term gains and qualified dividends and by retired + # military benefits. Item 10 of the Tax Commissioner's guideline lists + # military pensions and net gains as income. + military_retirement_income: 30_000 + long_term_capital_gains: 5_000 + qualified_dividend_income: 1_000 + output: + # 30,000 + 5,000 + 1,000, with no state-law subtraction. + nd_renters_refund_income: 36_000 diff --git a/policyengine_us/tests/test_nd_renters_refund_income_exclusions.py b/policyengine_us/tests/test_nd_renters_refund_income_exclusions.py new file mode 100644 index 00000000000..a8f40d4474f --- /dev/null +++ b/policyengine_us/tests/test_nd_renters_refund_income_exclusions.py @@ -0,0 +1,452 @@ +"""Invariants for the federal exclusions in North Dakota's renter's refund income. + +N.D.C.C. 57-02-08.1(5)(c) (https://ndlegis.gov/cencode/t57c02.pdf#page=16) +counts income "from all sources", but excludes "any amount excluded from +income by federal or state law with the exception of income from social +security benefits". nd_renters_refund_income starts from market_income. That +counts wages before payroll deductions, and the tax-exempt parts of interest, +pensions, retirement distributions, disability benefits and alimony, so the +variable subtracts nd_renters_refund_federally_excluded_income. The federal +model also deducts four exclusions above the line, so market_income holds that +income too, and the variable subtracts them as well (excluded_deductions). + +Four invariants hold for every input. + +1. Every market_income component has a stated treatment. Either federal gross + income counts it in full, or North Dakota subtracts the part federal law + excludes, or federal gross income leaves it out although federal law does + not exclude it (each named below, with the reason). Every above-the-line + deduction in the federal model is likewise either a deduction, which leaves + the income in, or an exclusion, which North Dakota subtracts. A new + component or deduction fails until it is classified, so a wider + market_income cannot bring excluded income into the refund again, as + #10663's wider market_income did. +2. Each excluded part lies between zero and the market_income component it + comes from, so the exclusions never exceed what those components hold. The + exclusions the model deducts above the line come only out of the market + income left after the others, so social security always counts in full. +3. Differential: for a filer whose income comes only from sources that + market_income and federal gross income share, renter's refund income equals + irs_gross_income less the exclusions the federal model deducts above the + line. The statute's federal exclusions and the federal model agree on every + shared source. +4. Moving a dollar from the taxable form of a source to its excluded form + lowers renter's refund income by that dollar. A dollar of social security + raises it by a dollar, however much of the benefit federal law taxes. + +YAML pins one household per case. These need random amounts over many +households at once, and lists read from market_income's source file and the +federal parameters. +""" + +import re +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +PACKAGE = Path(__file__).parent.parent +MARKET_INCOME = PACKAGE / "variables/household/income/person/general/market_income.py" +GROSS_INCOME_SOURCES = PACKAGE / "parameters/gov/irs/gross_income/sources.yaml" +ABOVE_THE_LINE = PACKAGE / "parameters/gov/irs/ald/deductions.yaml" +ND_DEDUCTED_EXCLUSIONS = ( + PACKAGE + / "parameters/gov/states/nd/tax/property/renters_refund/excluded_deductions.yaml" +) +YEAR = 2026 +# Amounts are float32 in the model. +TOLERANCE = 0.5 + +# Components federal gross income counts in full, and the gross income source +# that holds each. +COUNTED_IN_FULL = { + "self_employment_income": "self_employment_income", + "sstb_self_employment_income": "sstb_self_employment_income", + "partnership_s_corp_income": "partnership_s_corp_income", + "dividend_income": "dividend_income", + "capital_gains": "capital_gains", + "non_sch_d_capital_gains": "non_sch_d_capital_gains", + "other_net_gain_person": "other_net_gain_gross_income", + "rental_income": "rental_income", + "farm_rent_income": "farm_rent_income", + "estate_income": "estate_income", + "illicit_income": "illicit_income", + "farm_operations_income": "farm_operations_income", + "miscellaneous_income": "miscellaneous_income", + "military_retirement_income": "military_retirement_income", + "ak_permanent_fund_dividend": "ak_permanent_fund_dividend", +} + +# Components with a part federal law excludes: the gross income source that +# holds the rest, and the law that excludes the part. +PARTLY_EXCLUDED = { + "employment_income": ( + "irs_employment_income", + "IRC 402(e)(3), 402(g)(1), 403(b)(1), 106, 125 and 129(a): pre-tax " + "payroll deductions", + ), + "interest_income": ("taxable_interest_income", "IRC 103(a)"), + "pension_income": ("taxable_pension_income", "IRC 72(b)(1), 402(c), 402A(d)"), + "retirement_distributions": ( + "taxable_retirement_distributions", + "IRC 408(d), 408A(d)(1)", + ), + "disability_benefits": ("taxable_disability_benefits", "IRC 104(a)(3)"), + "alimony_income": ( + "taxable_alimony_income", + "Pub. L. 115-97 section 11051: instruments executed after 2018", + ), +} + +# Components that federal gross income leaves out although federal law does +# not exclude them in general. North Dakota counts them. +COUNTED_THOUGH_GROSS_INCOME_OMITS = { + "strike_benefits": "IRS Publication 525 counts strike benefits as income " + "unless the facts show the union meant them as gifts", + "gi_cash_assistance": "a guaranteed income payment is a gift (IRC 102) or " + "public assistance, which the statute counts, depending on the payer, and " + "the input records neither", +} + + +# The federal model's above-the-line deductions. A deduction leaves the income +# it offsets in renter's refund income; the statute excludes only what federal +# law excludes from income. +ABOVE_THE_LINE_DEDUCTIONS = { + "loss_ald": "IRC 62(a)(3) and 1211(b): capital losses", + "self_employment_tax_ald": "IRC 164(f)", + "student_loan_interest_ald": "IRC 221", + "early_withdrawal_penalty": "IRC 62(a)(9)", + "alimony_expense_ald": "IRC 215, for the payer", + "educator_expense_ald": "IRC 62(a)(2)(D)", + "capped_qualified_tuition_expenses_ald": "IRC 222", + "domestic_production_ald": "IRC 199", + "health_savings_account_ald": "IRC 223(a): contributions outside payroll", + "self_employed_health_insurance_ald": "IRC 162(l)", + "self_employed_pension_contribution_ald": "IRC 404", + "traditional_ira_deduction": "IRC 219", + "charitable_contributions_ald": "IRC 62(a)(22)", +} +# Exclusions from gross income that the federal model deducts above the line, +# with market_income holding the income they exclude. North Dakota subtracts +# them. +ABOVE_THE_LINE_EXCLUSIONS = { + "us_bonds_for_higher_ed": "IRC 135(a)", + "qualified_adoption_assistance_expense": "IRC 137(a)", + "specified_possession_income": "IRC 931(a)", + "puerto_rico_income": "IRC 933", +} +# The two a household records on a person; the other two are tax unit inputs, +# which the YAML tests cover. +PERSON_DEDUCTED_EXCLUSIONS = [ + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", +] + + +def market_income_components(): + return set(re.findall(r'^\s+"([a-z0-9_]+)",', MARKET_INCOME.read_text(), re.M)) + + +def list_parameter_entries(path): + """Every name a list parameter has held in any period.""" + raw = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + return {name for block in raw["values"].values() for name in block} + + +def gross_income_sources(): + return list_parameter_entries(GROSS_INCOME_SOURCES) + + +def test_every_market_income_component_has_a_stated_treatment(): + lists = ( + set(COUNTED_IN_FULL), + set(PARTLY_EXCLUDED), + set(COUNTED_THOUGH_GROSS_INCOME_OMITS), + ) + for index, first in enumerate(lists): + for second in lists[index + 1 :]: + assert not first & second, first & second + classified = set().union(*lists) + components = market_income_components() + assert components - classified == set(), ( + f"market_income components with no North Dakota treatment: " + f"{sorted(components - classified)}. Say whether federal gross income " + "counts each in full, or subtract the part federal law excludes in " + "nd_renters_refund_federally_excluded_income." + ) + assert classified - components == set(), "stale entries above" + + +def test_each_treatment_matches_the_federal_gross_income_list(): + sources = gross_income_sources() + counted = set(COUNTED_IN_FULL.values()) | { + taxable for taxable, _ in PARTLY_EXCLUDED.values() + } + assert counted - sources == set() + # A component that federal gross income starts counting moves to + # COUNTED_IN_FULL. + assert not set(COUNTED_THOUGH_GROSS_INCOME_OMITS) & sources + + +def test_every_above_the_line_deduction_has_a_stated_treatment(): + assert not set(ABOVE_THE_LINE_DEDUCTIONS) & set(ABOVE_THE_LINE_EXCLUSIONS) + entries = list_parameter_entries(ABOVE_THE_LINE) + classified = set(ABOVE_THE_LINE_DEDUCTIONS) | set(ABOVE_THE_LINE_EXCLUSIONS) + assert entries - classified == set(), ( + f"above-the-line deductions with no North Dakota treatment: " + f"{sorted(entries - classified)}. Say whether each is a deduction, " + "which leaves its income in renter's refund income, or an exclusion " + "from gross income, which excluded_deductions subtracts." + ) + assert classified - entries == set(), "stale entries above" + assert list_parameter_entries(ND_DEDUCTED_EXCLUSIONS) == set( + ABOVE_THE_LINE_EXCLUSIONS + ) + + +def situation(households): + """Single adults in North Dakota, one per household.""" + people, groups = {}, {} + for index, inputs in enumerate(households): + name = f"person_{index}" + people[name] = {"age": {YEAR: 40}, **{k: {YEAR: v} for k, v in inputs.items()}} + groups[name] = {"members": [name]} + return { + "people": people, + "tax_units": {f"tax_unit_{k}": v for k, v in groups.items()}, + "spm_units": {f"spm_unit_{k}": v for k, v in groups.items()}, + "families": {f"family_{k}": v for k, v in groups.items()}, + "marital_units": {f"marital_unit_{k}": v for k, v in groups.items()}, + "households": { + f"household_{k}": {**v, "state_code": {YEAR: "ND"}} + for k, v in groups.items() + }, + } + + +def calculate(households, *names): + simulation = Simulation(situation=situation(households)) + return [simulation.calculate(name, YEAR).astype(float) for name in names] + + +amount = st.integers(min_value=0, max_value=40_000) + + +@st.composite +def shared_sources(draw): + """Nonnegative income from sources market_income and gross income share.""" + deductions = { + name: draw(st.integers(min_value=0, max_value=8_000)) + for name in ( + "traditional_401k_contributions", + "traditional_403b_contributions", + "pre_tax_health_insurance_premiums", + "health_savings_account_payroll_contributions", + "dependent_care_employer_benefits", + ) + } + disability = draw(amount) + other_wages = draw(st.integers(min_value=0, max_value=150_000)) + taxable_interest = draw(amount) + inputs = { + # Wages cover the payroll deductions and any adoption assistance. + "employment_income": sum(deductions.values()) + other_wages, + **deductions, + # Exclusions the federal model deducts above the line, each within the + # income that holds it. + "qualified_adoption_assistance_expense": draw( + st.integers(min_value=0, max_value=other_wages) + ), + "us_bonds_for_higher_ed": draw( + st.integers(min_value=0, max_value=taxable_interest) + ), + "taxable_interest_income": taxable_interest, + "tax_exempt_interest_income": draw(amount), + "taxable_private_pension_income": draw(amount), + "tax_exempt_private_pension_income": draw(amount), + "taxable_public_pension_income": draw(amount), + "tax_exempt_public_pension_income": draw(amount), + "taxable_ira_distributions": draw(amount), + "tax_exempt_ira_distributions": draw(amount), + "taxable_401k_distributions": draw(amount), + "tax_exempt_401k_distributions": draw(amount), + "disability_benefits": disability, + "tax_exempt_disability_benefits": draw( + st.integers(min_value=0, max_value=disability) + ), + "alimony_income": draw(amount), + "divorce_year": draw(st.sampled_from([2010, 2018, 2019, 2024])), + "self_employment_income": draw(amount), + "qualified_dividend_income": draw(amount), + "non_qualified_dividend_income": draw(amount), + "long_term_capital_gains": draw(amount), + "rental_income": draw(amount), + "miscellaneous_income": draw(amount), + "military_retirement_pay": draw(amount), + } + # Leave out a random subset, so zero amounts and missing inputs both occur. + keep = draw(st.sets(st.sampled_from(sorted(inputs)))) + kept = {k: v for k, v in inputs.items() if k in keep or k == "employment_income"} + # Taxable interest holds the savings bond interest. + if "taxable_interest_income" not in kept: + kept.pop("us_bonds_for_higher_ed", None) + return kept + + +@settings(max_examples=12, deadline=None) +@given(households=st.lists(shared_sources(), min_size=1, max_size=12)) +@example( + households=[ + { + "employment_income": 50_000, + "traditional_401k_contributions": 5_000, + "tax_exempt_interest_income": 5_000, + "tax_exempt_private_pension_income": 4_000, + "alimony_income": 12_000, + "divorce_year": 2020, + "taxable_interest_income": 3_000, + "us_bonds_for_higher_ed": 2_000, + "qualified_adoption_assistance_expense": 4_000, + } + ] +) +def test_renters_refund_income_equals_gross_income_on_shared_sources(households): + nd_income, gross_income = calculate( + households, "nd_renters_refund_income", "irs_gross_income" + ) + deducted = [ + sum(inputs.get(name, 0) for name in ABOVE_THE_LINE_EXCLUSIONS) + for inputs in households + ] + expected = [gross - out for gross, out in zip(gross_income, deducted)] + assert list(nd_income) == pytest.approx(expected, abs=TOLERANCE) + + +# Inputs for the bounds test: shared sources, plus totals reported without +# their split and tax-exempt parts larger than the totals they belong to. +TOTALS = ("interest_income", "pension_income", "retirement_distributions") + + +@st.composite +def any_sources(draw): + inputs = draw(shared_sources()) + for name in draw(st.sets(st.sampled_from(TOTALS))): + inputs[name] = draw(amount) + if draw(st.booleans()): + inputs["tax_exempt_disability_benefits"] = inputs.get( + "disability_benefits", 0 + ) + draw(amount) + inputs["capital_gains"] = draw(st.integers(min_value=-60_000, max_value=40_000)) + inputs["strike_benefits"] = draw(amount) + inputs["social_security_retirement"] = draw(amount) + # Person-level exclusions larger than the income that should hold them. + for name in draw(st.sets(st.sampled_from(PERSON_DEDUCTED_EXCLUSIONS))): + inputs[name] = draw(st.integers(min_value=0, max_value=400_000)) + return inputs + + +@settings(max_examples=12, deadline=None) +@given(households=st.lists(any_sources(), min_size=1, max_size=12)) +def test_each_excluded_part_lies_within_its_component(households): + names = [ + "nd_renters_refund_federally_excluded_income", + "nd_renters_refund_income", + "market_income", + "social_security", + ] + names += list(PARTLY_EXCLUDED) + excluded, nd_income, market, social_security, *components = calculate( + households, *names + ) + ceiling = sum(component.clip(min=0) for component in components) + assert (excluded >= -TOLERANCE).all() + assert (excluded <= ceiling + TOLERANCE).all() + assert (nd_income >= 0).all() + # The exclusions the model deducts above the line come out of the market + # income left after the person-level parts, never out of social security. + floor = np.minimum(social_security, market + social_security - excluded) + assert (nd_income >= floor - TOLERANCE).all() + + +# Each pair moves a dollar from the taxable form of a source to its excluded +# form. +SHIFTS = [ + ("taxable_interest_income", "tax_exempt_interest_income"), + ("taxable_private_pension_income", "tax_exempt_private_pension_income"), + ("taxable_public_pension_income", "tax_exempt_public_pension_income"), + ("taxable_ira_distributions", "tax_exempt_ira_distributions"), + ("taxable_401k_distributions", "tax_exempt_401k_distributions"), + ("taxable_403b_distributions", "tax_exempt_403b_distributions"), + ("taxable_sep_distributions", "tax_exempt_sep_distributions"), +] + + +@settings(max_examples=12, deadline=None) +@given( + shift=st.sampled_from(SHIFTS), + taxable=st.integers(min_value=1, max_value=60_000), + exempt=st.integers(min_value=0, max_value=60_000), + share=st.floats(min_value=0, max_value=1), + wages=st.integers(min_value=0, max_value=100_000), + social_security=st.integers(min_value=0, max_value=40_000), +) +def test_a_dollar_moved_to_its_excluded_form_leaves_income( + shift, taxable, exempt, share, wages, social_security +): + taxable_name, exempt_name = shift + moved = round(taxable * share) + base = { + "employment_income": wages, + "social_security_retirement": social_security, + taxable_name: taxable, + exempt_name: exempt, + } + shifted = {**base, taxable_name: taxable - moved, exempt_name: exempt + moved} + # A wage dollar deferred into a traditional 401(k), and a disability + # dollar from a plan the recipient paid for, leave the same way. + deferral = round(wages * share) + deferred = {**base, "traditional_401k_contributions": deferral} + disability = {**base, "disability_benefits": exempt + moved} + paid_for = {**disability, "tax_exempt_disability_benefits": moved} + # Social security counts in full however much of it is taxed. + more_social_security = { + **base, + "social_security_retirement": social_security + moved, + } + # A dollar of taxable interest that IRC 135 excludes leaves too, though + # the federal model deducts it above the line. + interest = {**base, "taxable_interest_income": taxable} + bonds = {**interest, "us_bonds_for_higher_ed": moved} + households = [ + base, + shifted, + deferred, + disability, + paid_for, + more_social_security, + interest, + bonds, + ] + (income,) = calculate(households, "nd_renters_refund_income") + assert income[0] - income[1] == pytest.approx(moved, abs=TOLERANCE) + assert income[0] - income[2] == pytest.approx(deferral, abs=TOLERANCE) + assert income[3] - income[4] == pytest.approx(moved, abs=TOLERANCE) + assert income[5] - income[0] == pytest.approx(moved, abs=TOLERANCE) + assert income[6] - income[7] == pytest.approx(moved, abs=TOLERANCE) + + +def test_alimony_leaves_income_only_under_a_post_2018_instrument(): + households = [ + {"employment_income": 10_000, "alimony_income": 12_000, "divorce_year": year} + for year in (2015, 2018, 2019, 2024) + ] + (income,) = calculate(households, "nd_renters_refund_income") + assert list(income) == pytest.approx( + [22_000, 22_000, 10_000, 10_000], abs=TOLERANCE + ) diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.py new file mode 100644 index 00000000000..89ff74f4548 --- /dev/null +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.py @@ -0,0 +1,104 @@ +from policyengine_us.model_api import * + + +class nd_renters_refund_federally_excluded_income(Variable): + value_type = float + entity = Person + label = "North Dakota Renter's Refund market income excluded by federal law" + unit = USD + definition_period = YEAR + documentation = ( + "The parts of a person's market_income components that federal law " + "leaves out of gross income, which N.D.C.C. 57-02-08.1(5)(c) excludes " + "from renter's refund income: the payroll deductions federal law " + "excludes from wages, tax-exempt interest, the nontaxable part of " + "pensions and of retirement account distributions, disability " + "benefits from a plan the recipient paid for, and alimony under a " + "divorce or separation instrument executed after 2018. Each part is at " + "least zero and at most the market_income component it comes from. " + "nd_renters_refund_income also subtracts the federal exclusions the " + "model deducts above the line." + ) + reference = ( + # N.D.C.C. 57-02-08.1(5)(c). + "https://ndlegis.gov/cencode/t57c02.pdf#page=16", + # Payroll deductions: elective deferrals (402(e)(3), 402(g)(1)), + # 403(b) contributions, employer health coverage and HSA + # contributions (106(a), 106(d)), cafeteria plans (125(a)) and + # dependent care assistance (129(a)). + "https://www.law.cornell.edu/uscode/text/26/402", + "https://www.law.cornell.edu/uscode/text/26/403#b_1", + "https://www.law.cornell.edu/uscode/text/26/106", + "https://www.law.cornell.edu/uscode/text/26/125#a", + "https://www.law.cornell.edu/uscode/text/26/129#a", + # Interest on state and local bonds. + "https://www.law.cornell.edu/uscode/text/26/103#a", + # Pensions and retirement accounts: the investment in the contract + # (72(b)(1)), rollovers (402(c)(1), 408(d)(3)), and qualified Roth + # distributions (402A(d)(1), 408A(d)(1)). + "https://www.law.cornell.edu/uscode/text/26/72#b_1", + "https://www.law.cornell.edu/uscode/text/26/402A#d_1", + "https://www.law.cornell.edu/uscode/text/26/408#d", + "https://www.law.cornell.edu/uscode/text/26/408A#d_1", + # Disability benefits from a plan the recipient paid for. + "https://www.law.cornell.edu/uscode/text/26/104#a_3", + # Alimony under an instrument executed after 2018. + "https://www.irs.gov/taxtopics/tc452", + ) + defined_for = StateCode.ND + + def formula(person, period, parameters): + def excluded_part(component, excluded): + # Never negative, and never more than the market_income component + # the amount comes from, so an input that reports only the + # component's total keeps all of it as income. + return min_(max_(excluded, 0), max_(component, 0)) + + # Federal gross income counts wages as irs_employment_income, which + # subtracts the pre-tax payroll deductions in pre_tax_contributions + # (traditional 401(k) and 403(b) deferrals, health premiums and HSA + # contributions through payroll, and dependent care assistance), plus + # the dependent care assistance above the IRC 129 limit. Pre-tax + # premiums leave income here, and itemized_medical_expenses leaves + # them out, so no premium is subtracted twice. + wages = person("employment_income", period) + counted_wages = person("irs_employment_income", period) + person( + "taxable_dependent_care_benefits", period + ) + payroll_exclusions = excluded_part(wages, wages - counted_wages) + # Interest on state and local bonds (IRC 103(a)). + interest = excluded_part( + person("interest_income", period), + person("tax_exempt_interest_income", period), + ) + # The investment in the contract, rollovers and qualified distributions + # from designated Roth accounts (IRC 72(b)(1), 402(c)(1), 402A(d)(1)). + pensions = excluded_part( + person("pension_income", period), + person("tax_exempt_pension_income", period), + ) + # Qualified Roth IRA distributions, rollovers and nondeductible + # contributions returned (IRC 408(d), 408A(d)(1)). + retirement_distributions = excluded_part( + person("retirement_distributions", period), + person("tax_exempt_retirement_distributions", period), + ) + # Benefits from a plan the recipient paid for (IRC 104(a)(3)). + disability_benefits = excluded_part( + person("disability_benefits", period), + person("tax_exempt_disability_benefits", period), + ) + # Alimony under an instrument executed after 2018 is not included in + # the recipient's gross income; taxable_alimony_income holds the rest. + alimony = person("alimony_income", period) + excluded_alimony = excluded_part( + alimony, alimony - person("taxable_alimony_income", period) + ) + return ( + payroll_exclusions + + interest + + pensions + + retirement_distributions + + disability_benefits + + excluded_alimony + ) 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 6a85ef76629..d35e5dddce5 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 @@ -14,7 +14,14 @@ class nd_renters_refund_income(Variable): defined_for = StateCode.ND documentation = ( - "Renter refund income after unreimbursed medical costs actually paid. " + "Renter refund income: the listed income sources, less the parts of " + "market income that federal law excludes and the unreimbursed medical " + "costs actually paid, and at least zero. The excluded parts are those " + "of each person's market income components " + "(nd_renters_refund_federally_excluded_income) and the federal " + "exclusions the model deducts above the line (excluded_deductions), " + "which come out of the market income left after the first. Social " + "security counts in full. " "The paid-cost base restores the self-employed premiums excluded from " "the federal subtotal: by the federal formula, or as supplied with a " "caller's subtotal in itemized_medical_expenses_applied_exclusion. A " @@ -26,17 +33,28 @@ class nd_renters_refund_income(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund - # NDCC 57-02-08.1(5)(c) excludes income excluded by federal or state - # law. market_income includes wages before payroll deductions, so - # subtract the separate pretax premium input once from this base. - # 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"]) + # NDCC 57-02-08.1(5)(c) excludes "any amount excluded from income by + # federal or state law with the exception of income from social + # security benefits". market_income counts wages before payroll + # deductions and the tax-exempt parts of several sources in full, so + # subtract the parts federal law excludes. Social security stays whole. + person_exclusions = add( + tax_unit, period, ["nd_renters_refund_federally_excluded_income"] + ) + # The model deducts some federal exclusions above the line, so + # market_income still holds the income they exclude: savings bond + # interest spent on higher education (IRC 135), employer adoption + # assistance (IRC 137) and income from Puerto Rico and the specified + # possessions (IRC 931, 933). A household may record the first two on a + # dependent, so they come out of the tax unit's market income left after + # the person-level exclusions, and never out of social security. + market_income_left = max_( + add(tax_unit, period, ["market_income"]) - person_exclusions, 0 + ) + deducted_exclusions = min_( + max_(add(tax_unit, period, p.excluded_deductions), 0), + market_income_left, + ) # Restore premiums excluded from the federal subtotal (computed, or # supplied with a caller's subtotal). medical_expenses = add( @@ -49,8 +67,8 @@ def formula(tax_unit, period, parameters): ) return max_( add(tax_unit, period, p.income_sources) - - excluded_premiums - - excluded_disability_benefits + - person_exclusions + - deducted_exclusions - medical_expenses, 0, ) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index b6f53840be8..85b4d047bf1 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -6756,6 +6756,107 @@ 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-federally-excluded-income + program: renter-credit + years: + from: 2025 + type: tax + question: Does the renter's refund count the interest, pensions, IRA distributions, wages and alimony that federal + law leaves out of gross income, when the Tax Commissioner's guideline and the application list those sources as + income with no exception? + 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 10 + url: https://www.tax.nd.gov/sites/www/files/documents/guidelines/homestead-veterans-renters/credits-for-nd-homeowners-renters-guideline.pdf#page=2 + - kind: form + citation: SFN 24777 (10-2025), lines 5 to 7 and the instructions for lines 4 to 9 + url: https://www.tax.nd.gov/sites/www/files/documents/forms/homestead-disabled-renters/renters-refund-application.pdf#page=1 + readings: + statute: Income comes from all sources but leaves out "any amount excluded from income by federal or state law + with the exception of income from social security benefits". Federal law leaves out of gross income the interest + on state and local bonds (IRC 103(a)) and savings bond interest spent on higher education (IRC 135(a)); the return + of a pension's cost, rollovers and qualified Roth distributions (IRC 72(b)(1), 402(c)(1), 402A(d)(1), 408(d)(3), + 408A(d)(1)); the pre-tax payroll deductions of IRC 106, 125, 129, 402(e)(3) and 403(b)(1) and employer adoption + assistance (IRC 137(a)); and alimony under a divorce or separation instrument executed after 2018. + other: Item 10 lists pensions "including military pension and I.R.A.", salaries and wages, dividends and interest, + and alimony as income. The application asks for income from salary and wages (line 5) and from interest (line + 6), and its instructions for lines 4 to 9 repeat the list. Neither leaves out the amounts federal law excludes. + example: A renter with $20,000 of wages, $1,000 of taxable interest and $5,000 of interest on municipal bonds has + income of $21,000 under the statute and $26,000 under item 10 and line 6. + pe_us: + follows: statute + code: + - policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_federally_excluded_income.py + - policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py + - policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/excluded_deductions.yaml + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10697 + state: ruled + note: The statute names the exclusion, so it governs over the general lists, as in + us-nd:renters-refund-insurance-proceeds. Social security counts in full by the statute's own exception. The + state-law half of the clause has its own entry, us-nd:renters-refund-state-income-tax-reductions. +- id: us-nd:renters-refund-state-income-tax-reductions + program: renter-credit + years: + from: 2025 + type: tax + question: Do the amounts North Dakota's income tax reductions subtract, such as military pay, retired military + benefits and 40 percent of net long-term capital gains and qualified dividends, count as "excluded from income by + federal or state law" for the renter's refund? + sources: + - kind: statute + citation: N.D.C.C. 57-02-08.1(5)(c) + url: https://ndlegis.gov/cencode/t57c02.pdf#page=16 + - kind: statute + citation: N.D.C.C. 57-38-30.3(2) + url: https://ndlegis.gov/cencode/t57c38.pdf#page=33 + - kind: statute + citation: House Bill 1174 (2019), enrolled, sections 1 and 3 + url: https://ndlegis.gov/assembly/66-2019/regular/documents/19-0115-05000.pdf#page=1 + - kind: statute + citation: House Bill 1053 (2019), enrolled + url: https://ndlegis.gov/assembly/66-2019/regular/documents/19-0357-02000.pdf#page=1 + - kind: agency-practice + citation: Office of Tax Commissioner, fiscal note on House Bill 1174 (2019), on section 1 + url: https://ndlegis.gov/files/resource/66-2019/library/hb1174.pdf#page=1 + - kind: guidance + citation: Property Tax Credits for North Dakota Homeowners and Renters, item 10 + url: https://www.tax.nd.gov/sites/www/files/documents/guidelines/homestead-veterans-renters/credits-for-nd-homeowners-renters-guideline.pdf#page=2 + - kind: form + citation: SFN 24777 (10-2025), line 7 + url: https://www.tax.nd.gov/sites/www/files/documents/forms/homestead-disabled-renters/renters-refund-application.pdf#page=1 + readings: + statute: Income leaves out "any amount excluded from income by federal or state law with the exception of income + from social security benefits". N.D.C.C. 57-38-30.3(2) defines North Dakota taxable income as federal taxable + income "adjusted as follows", and most adjustments open "Reduced by". The list names military pay, retired + military personnel benefits and 40 percent of net long-term capital gains and qualified dividends, along with + plain deductions such as organ donation expenses. In the same chapter, 57-38-04 says certain compensation "must + be excluded from income". House Bill 1174 (2019) created the social security reduction in section 3 and added the + exception in section 1. In its fiscal note, the Tax Commissioner's office wrote that section 1 keeps the full + benefit in the homestead income test "even though they are excluded from income tax". House Bill 1053 (2019) + created the retired military reduction and added no exception for it. + other: Item 10 of the guideline, in the edition with content revised in July 2025, lists "Pensions, including + military pension and I.R.A." and "Net gains from the sale of property" as income. SFN 24777 line 7 asks for + capital gains. + example: A renter with $30,000 of retired military benefits and no other income has income of $30,000 if the benefits + count and $0 if the reduction excludes them. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10697 + state: ruled + note: N.D.C.C. 57-02-08.1(5)(c) doesn't say whether these reductions exclude amounts from income, so PolicyEngine + follows the Tax Commissioner's reading and counts them. The Legislative Assembly wrote the reductions as adjustments + to taxable income. In its fiscal note, the Tax Commissioner's office tied the exception for social security to the + reduction House Bill 1174 created. The exception also keeps in income the part of benefits that IRC 86 leaves out + of gross income, so it has effect even if the reductions exclude nothing. N.D.C.C. 1-02-39 lets a court weigh + legislative history and administrative construction when a statute is ambiguous, and the Commissioner kept military + pensions and net gains in item 10 after both 2019 Acts. - id: us-nd:renters-refund-insurance-proceeds program: renter-credit years: