From edf7cf13396ceffa22acb28da2b1e7811cbfce04 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:50:49 -0400 Subject: [PATCH 01/11] Add North Carolina's claim of right repayment itemized deduction G.S. 105-153.5(a)(2)d, added by S.L. 2016-5 for 2014 and later, lets North Carolina itemizers deduct repayments of income included in AGI in an earlier year under a claim of right (Form D-400 Schedule A line 8). Repayments above $3,000 are deductible in full; smaller ones are reduced by 2% of federal AGI less other miscellaneous itemized deductions, which the 2016 and 2025 NCDOR worksheets both reproduce. PE had no input for these repayments, so it adds claim_of_right_repayment (person level; only the head's and spouse's count) and moves North Carolina's uncapped itemized items into a parameter list. Co-Authored-By: Claude Opus 5.5 --- .../nc-claim-of-right-deduction.added.md | 1 + .../itemized/claim_of_right/threshold.yaml | 16 ++ .../deductions/itemized/other_sources.yaml | 17 ++ .../itemized/nc_claim_of_right_deduction.yaml | 266 ++++++++++++++++++ .../unit/test_nc_claim_of_right_properties.py | 137 +++++++++ .../itemized/nc_claim_of_right_deduction.py | 54 ++++ .../deductions/nc_itemized_deductions.py | 9 +- .../person/claim_of_right_repayment.py | 16 ++ 8 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 changelog.d/nc-claim-of-right-deduction.added.md create mode 100644 policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/threshold.yaml create mode 100644 policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/other_sources.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml create mode 100644 policyengine_us/tests/unit/test_nc_claim_of_right_properties.py create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py create mode 100644 policyengine_us/variables/household/expense/person/claim_of_right_repayment.py diff --git a/changelog.d/nc-claim-of-right-deduction.added.md b/changelog.d/nc-claim-of-right-deduction.added.md new file mode 100644 index 00000000000..d9a675b5026 --- /dev/null +++ b/changelog.d/nc-claim-of-right-deduction.added.md @@ -0,0 +1 @@ +Add North Carolina's itemized deduction for repaying income received under a claim of right (G.S. 105-153.5(a)(2)d), with a new claim_of_right_repayment input. diff --git a/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/threshold.yaml b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/threshold.yaml new file mode 100644 index 00000000000..941fc92e523 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/threshold.yaml @@ -0,0 +1,16 @@ +description: North Carolina allows the whole repayment of claim of right income as an itemized deduction when the repayment exceeds this amount; smaller repayments are reduced by the federal two percent floor on miscellaneous itemized deductions. +values: + 2014-01-01: 3_000 +metadata: + unit: currency-USD + period: year + label: North Carolina claim of right repayment threshold + reference: + - title: N.C. Gen. Stat. § 105-153.5(a)(2)d + href: https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html + - title: S.L. 2016-5, Section 2.1.(a) and (d) (effective for taxable years beginning on or after January 1, 2014) + href: https://www.ncleg.gov/EnactedLegislation/SessionLaws/PDF/2015-2016/SL2016-5.pdf#page=4 + - title: 2021 Form D-401 instructions, Form D-400 Schedule A line 8, Repayment of Claim of Right Worksheet + href: https://www.ncdor.gov/2021-d-401-individual-income-tax-instructions/open#page=20 + - title: 2025 Form D-401 instructions, Form D-400 Schedule A line 8, Repayment of Claim of Right Worksheet + href: https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20 diff --git a/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/other_sources.yaml b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/other_sources.yaml new file mode 100644 index 00000000000..822f8c60db7 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/other_sources.yaml @@ -0,0 +1,17 @@ +description: North Carolina adds these deductions to the capped mortgage interest and property tax deduction to compute itemized deductions. +values: + 2021-01-01: + - charitable_deduction + - medical_expense_deduction + - nc_claim_of_right_deduction +metadata: + unit: list + period: year + label: North Carolina itemized deductions other than mortgage interest and property taxes + reference: + - title: N.C. Gen. Stat. § 105-153.5(a)(2) + href: https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html + - title: 2021 Form D-401 instructions, Form D-400 Schedule A lines 6 through 10 + href: https://www.ncdor.gov/2021-d-401-individual-income-tax-instructions/open#page=20 + - title: "2025 Form D-401 instructions, Form D-400 Schedule A lines 6 through 10 #page 20-21" + href: https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml new file mode 100644 index 00000000000..95ed5ac8613 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml @@ -0,0 +1,266 @@ +# N.C. Gen. Stat. 105-153.5(a)(2)d and the Repayment of Claim of Right +# Worksheet (2025 Form D-401 instructions, PDF p. 20; 2016 Form D-401 +# instructions, PDF p. 13). Worksheet for repayments of $3,000 or less, 2018 +# and later: line 3 = 2% of federal AGI (zero if negative), line 4 = line 1 - +# line 3. Repayments above $3,000 are deductible in full. + +- name: Repayment of $3,000 or less is reduced by 2% of federal AGI. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # Worksheet: line 1 2,500; line 3 = 0.02 * 50,000 = 1,000; line 4 = 1,500. + nc_claim_of_right_deduction: 1_500 + +- name: A repayment of exactly $3,000 is still reduced by the floor. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 3_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 100_000 + households: + household: + members: [head] + state_code: NC + output: + # "If the repayment is three thousand dollars ($3,000) or less": + # 3,000 - 0.02 * 100,000 = 1,000. + nc_claim_of_right_deduction: 1_000 + +- name: A repayment above $3,000 is deductible in full. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 3_001 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 100_000 + households: + household: + members: [head] + state_code: NC + output: + nc_claim_of_right_deduction: 3_001 + +- name: No deduction when 2% of AGI exceeds a small repayment. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 1_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 100_000 + households: + household: + members: [head] + state_code: NC + output: + # 1,000 - 2,000 is negative; the deduction is zero. + nc_claim_of_right_deduction: 0 + +- name: Negative AGI gives a zero floor. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: -5_000 + households: + household: + members: [head] + state_code: NC + output: + # Worksheet line 3: "If negative, enter the number zero". + nc_claim_of_right_deduction: 2_500 + +- name: Spouses' repayments are combined on a joint return; a dependent's is not. + period: 2023 + input: + people: + head: + age: 40 + claim_of_right_repayment: 1_500 + spouse: + age: 40 + claim_of_right_repayment: 1_000 + child: + age: 17 + claim_of_right_repayment: 4_000 + tax_units: + tax_unit: + members: [head, spouse, child] + adjusted_gross_income: 60_000 + marital_units: + marital_unit: + members: [head, spouse] + child_marital_unit: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: NC + output: + # The child's repayment belongs on the child's own return. Line 1 is + # 1,500 + 1,000 = 2,500, not above 3,000; 2,500 - 0.02 * 60,000 = 1,300. + nc_claim_of_right_deduction: 1_300 + +- name: From 2018 other miscellaneous expenses do not absorb the floor. + period: 2022 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + unreimbursed_business_employee_expenses: 600 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # Section 67(g) disallows other miscellaneous itemized deductions from + # 2018, so the worksheet subtracts the whole 2% floor: 2,500 - 1,000. + nc_claim_of_right_deduction: 1_500 + +- name: Before 2018 other miscellaneous deductions absorb the floor first. + period: 2016 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + unreimbursed_business_employee_expenses: 600 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # 2016 worksheet: line 1 2,500; line 2 = 0.02 * 50,000 = 1,000; line 3 + # (federal Schedule A line 24) 3,100; line 4 = 3,100 - 2,500 = 600; + # line 5 = 1,000 - 600 = 400; line 6 = 2,500 - 400 = 2,100. + nc_claim_of_right_deduction: 2_100 + +- name: Before 2018 other miscellaneous deductions above the floor leave the whole repayment. + period: 2016 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + unreimbursed_business_employee_expenses: 2_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # Line 4 = 2,000; line 5 = 1,000 - 2,000, entered as zero; line 6 = 2,500. + nc_claim_of_right_deduction: 2_500 + +- name: No deduction outside North Carolina. + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: SC + output: + nc_claim_of_right_deduction: 0 + +- name: The deduction is added to itemized deductions outside the $20,000 cap. + period: 2024 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + mortgage_interest: 18_000 + real_estate_taxes: 6_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + adjusted_gross_income: 90_000 + charitable_deduction: 2_000 + medical_expense_deduction: 1_000 + households: + household: + members: [head] + state_code: NC + output: + # Schedule A: line 5 = min(18,000 + 6,000, 20,000) = 20,000; line 6 + # 2,000; line 7d 1,000; line 8 5,000; line 10 = 28,000. + nc_claim_of_right_deduction: 5_000 + nc_itemized_deductions: 28_000 + +- name: A full repayment makes itemizing worthwhile and lowers North Carolina tax. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 80_000 + claim_of_right_repayment: 5_000 + real_estate_taxes: 4_000 + tax_units: + tax_unit: + members: [head] + charitable_deduction: 8_000 + households: + household: + members: [head] + state_code: NC + output: + adjusted_gross_income: 80_000 + # Schedule A: 4,000 + 8,000 + 5,000 = 17,000, above the 12,750 single + # standard deduction. Without the repayment, 12,000 would lose to it. + nc_itemized_deductions: 17_000 + nc_standard_or_itemized_deductions: 17_000 + nc_taxable_income: 63_000 + # 63,000 * 4.25% = 2,677.50 (2,858.13 without the repayment). + nc_income_tax: 2_677.50 diff --git a/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py new file mode 100644 index 00000000000..f29106b2396 --- /dev/null +++ b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py @@ -0,0 +1,137 @@ +"""Properties of North Carolina's claim of right repayment deduction. + +N.C. Gen. Stat. 105-153.5(a)(2)d, added by S.L. 2016-5 for 2014 and later: a +repayment of $3,000 or less is deductible less (i) the section 67(a) floor, +two percent of federal AGI, minus (ii) the other miscellaneous itemized +deductions, not below zero; a larger repayment is deductible in full. + +Each test evaluates every point of a repayment x AGI x other miscellaneous +expenses x charitable deduction grid in one vectorized simulation, so the +properties hold for all grid inputs rather than for a few examples. YAML +cases cannot state these properties: + +- the deduction matches the line-by-line arithmetic of NCDOR's Repayment of + Claim of Right Worksheet: the 2016 version, which subtracts other + miscellaneous deductions from the floor, and the 2025 version, which + subtracts the whole floor because section 67(g) disallows those + deductions from 2018; +- it is non-negative, never exceeds the repayment, rises with the repayment + (also across the $3,000 threshold) and falls as AGI rises; +- it adds to North Carolina itemized deductions one for one, outside the + $20,000 mortgage and property tax cap, and never raises North Carolina + income tax. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +REPAYMENTS = [0, 1, 999, 1_000, 2_999, 3_000, 3_000.5, 3_001, 10_000, 250_000] +AGIS = [-5_000, 0, 50_000, 100_000, 150_000, 1_000_000] +OTHER_MISC = [0, 600, 5_000] +CHARITY = [0, 20_000] +GRID = list(itertools.product(REPAYMENTS, AGIS, OTHER_MISC, CHARITY)) +SHAPE = (len(REPAYMENTS), len(AGIS), len(OTHER_MISC), len(CHARITY)) +REPAYMENT, AGI, MISC, CHARITABLE = ( + np.array([point[i] for point in GRID], dtype=float) for i in range(4) +) + + +def grid_simulation(year): + """One single-person North Carolina tax unit per grid point.""" + situation = {"people": {}, "tax_units": {}, "households": {}} + for i in range(len(GRID)): + person = f"p{i}" + situation["people"][person] = { + "age": {year: 40}, + "claim_of_right_repayment": {year: REPAYMENT[i]}, + "unreimbursed_business_employee_expenses": {year: MISC[i]}, + } + situation["tax_units"][f"t{i}"] = { + "members": [person], + "adjusted_gross_income": {year: AGI[i]}, + "charitable_deduction": {year: CHARITABLE[i]}, + } + situation["households"][f"h{i}"] = { + "members": [person], + "state_code": {year: "NC"}, + } + return Simulation(situation=situation) + + +def worksheet_2016(): + """2016 Form D-401 instructions, PDF p. 13, lines 1-6.""" + line1 = REPAYMENT + line2 = 0.02 * np.maximum(AGI, 0) # federal Schedule A line 26 + line3 = MISC + REPAYMENT # federal Schedule A line 24 + line4 = line3 - line1 + line5 = np.maximum(line2 - line4, 0) + line6 = np.maximum(line1 - line5, 0) + return np.where(REPAYMENT > 3_000, REPAYMENT, line6) + + +def worksheet_2025(): + """2025 Form D-401 instructions, PDF p. 20, lines 1-4.""" + line3 = np.maximum(0.02 * AGI, 0) + line4 = np.maximum(REPAYMENT - line3, 0) + return np.where(REPAYMENT > 3_000, REPAYMENT, line4) + + +@pytest.fixture(scope="module") +def results(): + out = {} + for year in [2016, 2025]: + sim = grid_simulation(year) + out[year] = { + variable: np.asarray(sim.calculate(variable, year)) + for variable in [ + "nc_claim_of_right_deduction", + "nc_itemized_deductions", + "nc_income_tax", + ] + } + return out + + +@pytest.mark.parametrize( + "year, worksheet", [(2016, worksheet_2016), (2025, worksheet_2025)] +) +def test_deduction_matches_the_worksheet(results, year, worksheet): + np.testing.assert_allclose( + results[year]["nc_claim_of_right_deduction"], worksheet(), atol=1e-6 + ) + + +@pytest.mark.parametrize("year", [2016, 2025]) +def test_deduction_is_bounded_and_monotone(results, year): + deduction = results[year]["nc_claim_of_right_deduction"] + assert np.all(deduction >= 0) + assert np.all(deduction <= REPAYMENT + 1e-6) + np.testing.assert_allclose( + deduction[REPAYMENT > 3_000], REPAYMENT[REPAYMENT > 3_000] + ) + table = deduction.reshape(SHAPE) + assert np.all(np.diff(table, axis=0) >= -1e-6), "falls as the repayment rises" + assert np.all(np.diff(table, axis=1) <= 1e-6), "rises as AGI rises" + assert np.all(np.diff(table, axis=2) >= -1e-6), "falls as other expenses rise" + + +def test_other_miscellaneous_expenses_matter_only_before_2018(results): + for year, matters in [(2016, True), (2025, False)]: + table = results[year]["nc_claim_of_right_deduction"].reshape(SHAPE) + spread = np.abs(table - table[:, :, :1, :]).max() + assert (spread > 0) == matters, year + + +@pytest.mark.parametrize("year", [2016, 2025]) +def test_deduction_adds_to_itemized_deductions_and_never_raises_tax(results, year): + deduction = results[year]["nc_claim_of_right_deduction"].reshape(SHAPE) + itemized = results[year]["nc_itemized_deductions"].reshape(SHAPE) + tax = results[year]["nc_income_tax"].reshape(SHAPE) + # Row 0 of the repayment axis is the same tax unit with no repayment. + np.testing.assert_allclose(itemized - itemized[:1], deduction, atol=1e-6) + assert np.all(tax <= tax[:1] + 1e-6), "a repayment raised North Carolina tax" + assert np.all(np.diff(tax, axis=0) <= 1e-6), "tax rises with the repayment" diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py new file mode 100644 index 00000000000..4ac99a1259e --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py @@ -0,0 +1,54 @@ +from policyengine_us.model_api import * + + +class nc_claim_of_right_deduction(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina claim of right repayment itemized deduction" + unit = USD + documentation = ( + "North Carolina itemized deduction for repaying income included in " + "adjusted gross income in an earlier year under a claim of right. " + "Repayments above the threshold are deductible in full. Smaller " + "repayments are reduced by two percent of federal adjusted gross " + "income less the taxpayer's other miscellaneous itemized deductions. " + "Not modeled: the bar on the deduction when federal tax for the year " + "of repayment is computed under 26 U.S.C. 1341(a)(5)." + ) + definition_period = YEAR + reference = ( + # N.C. Gen. Stat. 105-153.5(a)(2)d + "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html", + # 2016 Form D-401 instructions, Repayment of Claim of Right Worksheet + "https://taxsim.nber.org/historical_state_tax_forms/NC/2016/D401.pdf#page=13", + # 2025 Form D-401 instructions, Form D-400 Schedule A line 8 and worksheet + "https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20", + ) + defined_for = StateCode.NC + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # A dependent's repayment belongs on the dependent's own return. + repayment = tax_unit.sum( + person("claim_of_right_repayment", period) + * person("is_tax_unit_head_or_spouse", period) + ) + p = parameters(period).gov.states.nc.tax.income.deductions.itemized + # Repayments of $3,000 or less are reduced by (i) the section 67(a) + # floor, two percent of federal AGI, minus (ii) the other + # miscellaneous itemized deductions, not below zero. Section 67(g) + # disallows those other deductions from 2018, so the 2018-and-later + # worksheet subtracts the whole floor. + p_misc = parameters(period).gov.irs.deductions.itemized.misc + floor = p_misc.floor * tax_unit("positive_agi", period) + if p_misc.applies: + other_misc_deductions = tax_unit("total_misc_deductions", period) + else: + other_misc_deductions = 0 + remaining_floor = max_(floor - other_misc_deductions, 0) + small_repayment_deduction = max_(repayment - remaining_floor, 0) + return where( + repayment > p.claim_of_right.threshold, + repayment, + small_repayment_deduction, + ) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py index 994735af1a7..037c46fd128 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py @@ -27,10 +27,9 @@ def formula(tax_unit, period, parameters): # North Carolina specifies a state and local tax deduction cap which is currently not modeled in PolicyEngine - other_deductions = add( - tax_unit, - period, - ["charitable_deduction", "medical_expense_deduction"], - ) + # Charitable contributions, medical and dental expenses, and the + # other items in G.S. 105-153.5(a)(2), none subject to the cap. + p = parameters(period).gov.states.nc.tax.income.deductions.itemized + other_deductions = add(tax_unit, period, p.other_sources) return capped_mortage_and_property_taxes + other_deductions diff --git a/policyengine_us/variables/household/expense/person/claim_of_right_repayment.py b/policyengine_us/variables/household/expense/person/claim_of_right_repayment.py new file mode 100644 index 00000000000..ed205b53da5 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/claim_of_right_repayment.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class claim_of_right_repayment(Variable): + value_type = float + entity = Person + label = "Repayment of income received under a claim of right" + unit = USD + documentation = ( + "Amount repaid this year of income that was included in adjusted gross " + "income in an earlier year because it appeared the person had an " + "unrestricted right to it (26 U.S.C. 1341). Excludes repayments deducted " + "in arriving at adjusted gross income this year." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/1341" From 3b57231636e5fe380306b3bd9bb6e61080f349b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 09:39:16 -0400 Subject: [PATCH 02/11] From 2026 limit North Carolina's claim of right deduction to income NC taxed Review r1 (subfleet 20261009-090558-nc-itemized-review): - S.L. 2026-31 section 1.8 rewrites G.S. 105-153.5(a)(2)d for taxable years beginning on or after January 1, 2026: the repaid amount must have been included in AGI "as modified in G.S. 105-153.5 and G.S. 105-153.6" in the earlier year. New person input nc_claim_of_right_repayment_excluded_from_income (the part NC deducted when received, such as repaid Social Security) and a dated parameter apply it. - Count only the head's and spouse's other miscellaneous expenses against the pre-2018 floor; a dependent's are on the dependent's return. - The property grid now varies property taxes (so the $20,000 cap binds) and adds a 2026 grid for the excluded part. Co-Authored-By: Claude Opus 5.5 --- .../nc_modified_income_basis.yaml | 13 ++ .../itemized/nc_claim_of_right_deduction.yaml | 107 +++++++++++++- .../unit/test_nc_claim_of_right_properties.py | 137 ++++++++++++------ .../itemized/nc_claim_of_right_deduction.py | 36 +++-- ...of_right_repayment_excluded_from_income.py | 20 +++ 5 files changed, 256 insertions(+), 57 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/nc_modified_income_basis.yaml create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_repayment_excluded_from_income.py diff --git a/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/nc_modified_income_basis.yaml b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/nc_modified_income_basis.yaml new file mode 100644 index 00000000000..ce8a8a199ec --- /dev/null +++ b/policyengine_us/parameters/gov/states/nc/tax/income/deductions/itemized/claim_of_right/nc_modified_income_basis.yaml @@ -0,0 +1,13 @@ +description: North Carolina limits the claim of right repayment deduction to repayments of amounts included in adjusted gross income, as modified for North Carolina, in the earlier year, if this is true. +values: + 2014-01-01: false + 2026-01-01: true +metadata: + unit: bool + period: year + label: North Carolina claim of right repayment deduction uses North Carolina modified income + reference: + - title: N.C. Gen. Stat. § 105-153.5(a)(2)d + href: https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html + - title: "S.L. 2026-31, Section 1.8.(a) and (b) (effective for taxable years beginning on or after January 1, 2026) #page 5-6" + href: https://www.ncleg.gov/EnactedLegislation/SessionLaws/PDF/2025-2026/SL2026-31.pdf#page=5 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml index 95ed5ac8613..b0eafe19c2f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml @@ -2,7 +2,9 @@ # Worksheet (2025 Form D-401 instructions, PDF p. 20; 2016 Form D-401 # instructions, PDF p. 13). Worksheet for repayments of $3,000 or less, 2018 # and later: line 3 = 2% of federal AGI (zero if negative), line 4 = line 1 - -# line 3. Repayments above $3,000 are deductible in full. +# line 3. Repayments above $3,000 are deductible in full. From 2026 (S.L. +# 2026-31 section 1.8, PDF pp. 5-6) only repayments of amounts included in AGI +# as modified for North Carolina count. - name: Repayment of $3,000 or less is reduced by 2% of federal AGI. period: 2025 @@ -193,6 +195,109 @@ # Line 4 = 2,000; line 5 = 1,000 - 2,000, entered as zero; line 6 = 2,500. nc_claim_of_right_deduction: 2_500 +- name: Before 2018 a dependent's miscellaneous expenses do not absorb the floor. + period: 2016 + input: + people: + head: + age: 40 + claim_of_right_repayment: 2_500 + child: + age: 10 + unreimbursed_business_employee_expenses: 600 + tax_units: + tax_unit: + members: [head, child] + adjusted_gross_income: 50_000 + households: + household: + members: [head, child] + state_code: NC + output: + # The child's expenses are not on the parent's federal Schedule A, so + # line 4 is zero: 2,500 - 0.02 * 50,000 = 1,500. + nc_claim_of_right_deduction: 1_500 + +- name: In 2025 a repayment of income North Carolina had excluded still counts. + period: 2025 + input: + people: + head: + age: 70 + claim_of_right_repayment: 5_000 + nc_claim_of_right_repayment_excluded_from_income: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # Before 2026 the test is inclusion in federal AGI. + nc_claim_of_right_deduction: 5_000 + +- name: From 2026 a repayment of income North Carolina excluded is not deductible. + period: 2026 + input: + people: + head: + age: 70 + claim_of_right_repayment: 5_000 + nc_claim_of_right_repayment_excluded_from_income: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # S.L. 2026-31 section 1.8: "an amount included in adjusted gross income, + # as modified in G.S. 105-153.5 and G.S. 105-153.6". For example, repaid + # Social Security benefits that North Carolina deducted when received. + nc_claim_of_right_deduction: 0 + +- name: From 2026 only the part North Carolina taxed counts, and the $3,000 test applies to it. + period: 2026 + input: + people: + head: + age: 70 + claim_of_right_repayment: 5_000 + nc_claim_of_right_repayment_excluded_from_income: 3_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + # Eligible repayment 2,000, not above 3,000: 2,000 - 0.02 * 50,000 = 1,000. + nc_claim_of_right_deduction: 1_000 + +- name: From 2026 a repayment North Carolina taxed in full is deductible as before. + period: 2026 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + nc_claim_of_right_deduction: 5_000 + - name: No deduction outside North Carolina. period: 2025 input: diff --git a/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py index f29106b2396..7ff5a694e8d 100644 --- a/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py +++ b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py @@ -3,23 +3,28 @@ N.C. Gen. Stat. 105-153.5(a)(2)d, added by S.L. 2016-5 for 2014 and later: a repayment of $3,000 or less is deductible less (i) the section 67(a) floor, two percent of federal AGI, minus (ii) the other miscellaneous itemized -deductions, not below zero; a larger repayment is deductible in full. - -Each test evaluates every point of a repayment x AGI x other miscellaneous -expenses x charitable deduction grid in one vectorized simulation, so the -properties hold for all grid inputs rather than for a few examples. YAML -cases cannot state these properties: - -- the deduction matches the line-by-line arithmetic of NCDOR's Repayment of - Claim of Right Worksheet: the 2016 version, which subtracts other - miscellaneous deductions from the floor, and the 2025 version, which - subtracts the whole floor because section 67(g) disallows those - deductions from 2018; +deductions, not below zero; a larger repayment is deductible in full. From +2026 (S.L. 2026-31 section 1.8) only repayments of amounts included in AGI as +modified for North Carolina count. + +YAML cases check single households. These tests check properties at every +point of a grid, one vectorized simulation per year, and compare the model +with independent line-by-line implementations of NCDOR's worksheets. A YAML +case cannot state a property that holds for all inputs, such as monotonicity +across the $3,000 threshold: + +- the deduction matches the 2016 Repayment of Claim of Right Worksheet, + which subtracts other miscellaneous deductions from the floor, and the + 2025 worksheet, which subtracts the whole floor because section 67(g) + disallows those deductions from 2018; - it is non-negative, never exceeds the repayment, rises with the repayment (also across the $3,000 threshold) and falls as AGI rises; -- it adds to North Carolina itemized deductions one for one, outside the - $20,000 mortgage and property tax cap, and never raises North Carolina - income tax. +- it adds to North Carolina itemized deductions one for one, including when + property taxes already exceed the $20,000 mortgage and property tax cap, + and never raises North Carolina income tax, including when the repayment + moves the filer from the standard deduction to itemizing; +- from 2026 it is the 2025 worksheet applied to the part of the repayment + North Carolina taxed when received; before 2026 that split has no effect. """ import itertools @@ -33,27 +38,34 @@ AGIS = [-5_000, 0, 50_000, 100_000, 150_000, 1_000_000] OTHER_MISC = [0, 600, 5_000] CHARITY = [0, 20_000] -GRID = list(itertools.product(REPAYMENTS, AGIS, OTHER_MISC, CHARITY)) -SHAPE = (len(REPAYMENTS), len(AGIS), len(OTHER_MISC), len(CHARITY)) -REPAYMENT, AGI, MISC, CHARITABLE = ( - np.array([point[i] for point in GRID], dtype=float) for i in range(4) +PROPERTY_TAX = [0, 25_000] +AXES = [REPAYMENTS, AGIS, OTHER_MISC, CHARITY, PROPERTY_TAX] +GRID = list(itertools.product(*AXES)) +SHAPE = tuple(len(axis) for axis in AXES) +REPAYMENT, AGI, MISC, CHARITABLE, PROPERTY = ( + np.array([point[i] for point in GRID], dtype=float) for i in range(len(AXES)) +) +# 2026 grid: repayment x share of it North Carolina excluded when received x AGI. +SHARES = [0, 0.25, 0.75, 1] +GRID_2026 = list(itertools.product(REPAYMENTS, SHARES, AGIS)) +REPAYMENT_2026, SHARE_2026, AGI_2026 = ( + np.array([point[i] for point in GRID_2026], dtype=float) for i in range(3) ) -def grid_simulation(year): +def grid_simulation(year, people, tax_units): """One single-person North Carolina tax unit per grid point.""" + size = len(next(iter(people.values()))) situation = {"people": {}, "tax_units": {}, "households": {}} - for i in range(len(GRID)): + for i in range(size): person = f"p{i}" situation["people"][person] = { "age": {year: 40}, - "claim_of_right_repayment": {year: REPAYMENT[i]}, - "unreimbursed_business_employee_expenses": {year: MISC[i]}, + **{name: {year: float(values[i])} for name, values in people.items()}, } situation["tax_units"][f"t{i}"] = { "members": [person], - "adjusted_gross_income": {year: AGI[i]}, - "charitable_deduction": {year: CHARITABLE[i]}, + **{name: {year: float(values[i])} for name, values in tax_units.items()}, } situation["households"][f"h{i}"] = { "members": [person], @@ -62,29 +74,37 @@ def grid_simulation(year): return Simulation(situation=situation) -def worksheet_2016(): +def worksheet_2016(repayment, agi, other_misc): """2016 Form D-401 instructions, PDF p. 13, lines 1-6.""" - line1 = REPAYMENT - line2 = 0.02 * np.maximum(AGI, 0) # federal Schedule A line 26 - line3 = MISC + REPAYMENT # federal Schedule A line 24 + line1 = repayment + line2 = 0.02 * np.maximum(agi, 0) # federal Schedule A line 26 + line3 = other_misc + repayment # federal Schedule A line 24 line4 = line3 - line1 line5 = np.maximum(line2 - line4, 0) line6 = np.maximum(line1 - line5, 0) - return np.where(REPAYMENT > 3_000, REPAYMENT, line6) + return np.where(repayment > 3_000, repayment, line6) -def worksheet_2025(): +def worksheet_2025(repayment, agi): """2025 Form D-401 instructions, PDF p. 20, lines 1-4.""" - line3 = np.maximum(0.02 * AGI, 0) - line4 = np.maximum(REPAYMENT - line3, 0) - return np.where(REPAYMENT > 3_000, REPAYMENT, line4) + line3 = np.maximum(0.02 * agi, 0) + line4 = np.maximum(repayment - line3, 0) + return np.where(repayment > 3_000, repayment, line4) @pytest.fixture(scope="module") def results(): out = {} for year in [2016, 2025]: - sim = grid_simulation(year) + sim = grid_simulation( + year, + { + "claim_of_right_repayment": REPAYMENT, + "unreimbursed_business_employee_expenses": MISC, + "real_estate_taxes": PROPERTY, + }, + {"adjusted_gross_income": AGI, "charitable_deduction": CHARITABLE}, + ) out[year] = { variable: np.asarray(sim.calculate(variable, year)) for variable in [ @@ -96,12 +116,16 @@ def results(): return out -@pytest.mark.parametrize( - "year, worksheet", [(2016, worksheet_2016), (2025, worksheet_2025)] -) -def test_deduction_matches_the_worksheet(results, year, worksheet): +def test_deduction_matches_the_worksheets(results): + np.testing.assert_allclose( + results[2016]["nc_claim_of_right_deduction"], + worksheet_2016(REPAYMENT, AGI, MISC), + atol=1e-6, + ) np.testing.assert_allclose( - results[year]["nc_claim_of_right_deduction"], worksheet(), atol=1e-6 + results[2025]["nc_claim_of_right_deduction"], + worksheet_2025(REPAYMENT, AGI), + atol=1e-6, ) @@ -110,9 +134,8 @@ def test_deduction_is_bounded_and_monotone(results, year): deduction = results[year]["nc_claim_of_right_deduction"] assert np.all(deduction >= 0) assert np.all(deduction <= REPAYMENT + 1e-6) - np.testing.assert_allclose( - deduction[REPAYMENT > 3_000], REPAYMENT[REPAYMENT > 3_000] - ) + above = REPAYMENT > 3_000 + np.testing.assert_allclose(deduction[above], REPAYMENT[above]) table = deduction.reshape(SHAPE) assert np.all(np.diff(table, axis=0) >= -1e-6), "falls as the repayment rises" assert np.all(np.diff(table, axis=1) <= 1e-6), "rises as AGI rises" @@ -122,7 +145,7 @@ def test_deduction_is_bounded_and_monotone(results, year): def test_other_miscellaneous_expenses_matter_only_before_2018(results): for year, matters in [(2016, True), (2025, False)]: table = results[year]["nc_claim_of_right_deduction"].reshape(SHAPE) - spread = np.abs(table - table[:, :, :1, :]).max() + spread = np.abs(table - table[:, :, :1]).max() assert (spread > 0) == matters, year @@ -135,3 +158,29 @@ def test_deduction_adds_to_itemized_deductions_and_never_raises_tax(results, yea np.testing.assert_allclose(itemized - itemized[:1], deduction, atol=1e-6) assert np.all(tax <= tax[:1] + 1e-6), "a repayment raised North Carolina tax" assert np.all(np.diff(tax, axis=0) <= 1e-6), "tax rises with the repayment" + + +def test_from_2026_only_the_part_north_carolina_taxed_counts(): + excluded = SHARE_2026 * REPAYMENT_2026 + deductions = {} + for year in [2025, 2026]: + sim = grid_simulation( + year, + { + "claim_of_right_repayment": REPAYMENT_2026, + "nc_claim_of_right_repayment_excluded_from_income": excluded, + }, + {"adjusted_gross_income": AGI_2026}, + ) + deductions[year] = np.asarray( + sim.calculate("nc_claim_of_right_deduction", year) + ) + np.testing.assert_allclose( + deductions[2025], worksheet_2025(REPAYMENT_2026, AGI_2026), atol=1e-6 + ) + np.testing.assert_allclose( + deductions[2026], + worksheet_2025(REPAYMENT_2026 - excluded, AGI_2026), + atol=1e-6, + ) + assert np.all(deductions[2026] <= deductions[2025] + 1e-6) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py index 4ac99a1259e..dbcbe3397ee 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py @@ -8,17 +8,21 @@ class nc_claim_of_right_deduction(Variable): unit = USD documentation = ( "North Carolina itemized deduction for repaying income included in " - "adjusted gross income in an earlier year under a claim of right. " - "Repayments above the threshold are deductible in full. Smaller " - "repayments are reduced by two percent of federal adjusted gross " - "income less the taxpayer's other miscellaneous itemized deductions. " - "Not modeled: the bar on the deduction when federal tax for the year " - "of repayment is computed under 26 U.S.C. 1341(a)(5)." + "adjusted gross income in an earlier year under a claim of right; from " + "2026, only income North Carolina taxed that year. Repayments above " + "the threshold are deductible in full. Smaller repayments are reduced " + "by two percent of federal adjusted gross income less the filers' " + "other miscellaneous itemized deductions. Not modeled: the bar on the " + "deduction when federal tax for the year of repayment is computed " + "under 26 U.S.C. 1341(a)(5), and from 2026, repayments of amounts North " + "Carolina added to adjusted gross income that federal law excluded." ) definition_period = YEAR reference = ( # N.C. Gen. Stat. 105-153.5(a)(2)d "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html", + # S.L. 2026-31, section 1.8, PDF pages 5-6 + "https://www.ncleg.gov/EnactedLegislation/SessionLaws/PDF/2025-2026/SL2026-31.pdf#page=5", # 2016 Form D-401 instructions, Repayment of Claim of Right Worksheet "https://taxsim.nber.org/historical_state_tax_forms/NC/2016/D401.pdf#page=13", # 2025 Form D-401 instructions, Form D-400 Schedule A line 8 and worksheet @@ -28,12 +32,18 @@ class nc_claim_of_right_deduction(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members - # A dependent's repayment belongs on the dependent's own return. - repayment = tax_unit.sum( - person("claim_of_right_repayment", period) - * person("is_tax_unit_head_or_spouse", period) - ) p = parameters(period).gov.states.nc.tax.income.deductions.itemized + repaid = person("claim_of_right_repayment", period) + if p.claim_of_right.nc_modified_income_basis: + # From 2026, only repayments of amounts included in AGI as + # modified for North Carolina in the earlier year. + excluded = person( + "nc_claim_of_right_repayment_excluded_from_income", period + ) + repaid = max_(repaid - excluded, 0) + # Dependents' repayments and expenses belong on their own returns. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + repayment = tax_unit.sum(repaid * head_or_spouse) # Repayments of $3,000 or less are reduced by (i) the section 67(a) # floor, two percent of federal AGI, minus (ii) the other # miscellaneous itemized deductions, not below zero. Section 67(g) @@ -42,7 +52,9 @@ def formula(tax_unit, period, parameters): p_misc = parameters(period).gov.irs.deductions.itemized.misc floor = p_misc.floor * tax_unit("positive_agi", period) if p_misc.applies: - other_misc_deductions = tax_unit("total_misc_deductions", period) + other_misc_deductions = tax_unit.sum( + add(person, period, p_misc.sources) * head_or_spouse + ) else: other_misc_deductions = 0 remaining_floor = max_(floor - other_misc_deductions, 0) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_repayment_excluded_from_income.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_repayment_excluded_from_income.py new file mode 100644 index 00000000000..d849340a81e --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_repayment_excluded_from_income.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class nc_claim_of_right_repayment_excluded_from_income(Variable): + value_type = float + entity = Person + label = "Claim of right repayment excluded from North Carolina income when received" + unit = USD + documentation = ( + "Part of claim_of_right_repayment that North Carolina deducted from " + "adjusted gross income in the earlier year it was received, such as " + "repaid Social Security benefits or Bailey-exempt retirement benefits." + ) + definition_period = YEAR + reference = ( + # N.C. Gen. Stat. 105-153.5(a)(2)d as rewritten by S.L. 2026-31, + # section 1.8, PDF pages 5-6 + "https://www.ncleg.gov/EnactedLegislation/SessionLaws/PDF/2025-2026/SL2026-31.pdf#page=5", + ) + defined_for = StateCode.NC From a6a96cd17380f27d14bdd38e643d0be2d20a455a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 12:37:20 -0400 Subject: [PATCH 03/11] Model the section 1341 claim of right deduction and credit; wire North Carolina's 1341(a)(5) bar and G.S. 105-266.2 payment (WIP) Co-Authored-By: Claude Opus 5.5 --- .../household_refundable_credits.yaml | 2 +- .../household_refundable_state_credits.yaml | 2 +- ...usehold_tax_before_refundable_credits.yaml | 2 +- .../gov/irs/credits/refundable.yaml | 3 + .../itemized/claim_of_right/threshold.yaml | 16 ++ .../irs/deductions/itemized_deductions.yaml | 3 + ..._income_tax_before_refundable_credits.yaml | 2 +- .../household/state_refundable_credits.yaml | 2 +- .../nc/tax/income/credits/refundable.yaml | 13 ++ .../reforms/states/nc/cdcc/nc_cdcc_reform.py | 36 ++-- .../reforms/states/nc/eitc/nc_eitc_reform.py | 37 ++-- .../claim_of_right/claim_of_right_credit.yaml | 26 +++ .../claim_of_right_credit_applies.yaml | 75 ++++++++ .../claim_of_right_section_1341_eligible.yaml | 51 ++++++ .../credits/claim_of_right/integration.yaml | 161 ++++++++++++++++++ .../tax_unit_claim_of_right_repayment.yaml | 40 +++++ .../itemizing/claim_of_right_deduction.yaml | 66 +++++++ .../nc_claim_of_right_payment.yaml | 54 ++++++ .../itemized/nc_claim_of_right_deduction.yaml | 98 +++++++++++ .../states/nc/tax/income/nc_income_tax.yaml | 12 ++ .../contrib/states/nc/cdcc/nc_cdcc.yaml | 34 ++++ .../contrib/states/nc/eitc/nc_eitc.yaml | 18 ++ .../nc/eitc/nc_eitc_excludes_use_tax.yaml | 4 +- .../claim_of_right/claim_of_right_credit.py | 32 ++++ .../claim_of_right_credit_applies.py | 39 +++++ .../claim_of_right_prior_year_tax_decrease.py | 23 +++ .../claim_of_right_section_1341_eligible.py | 25 +++ ...e_tax_if_claiming_claim_of_right_credit.py | 24 +++ ...ax_if_claiming_claim_of_right_deduction.py | 23 +++ .../tax_unit_claim_of_right_repayment.py | 27 +++ .../itemizing/claim_of_right_deduction.py | 32 ++++ .../nc_claim_of_right_payment.py | 33 ++++ ..._claim_of_right_prior_year_tax_increase.py | 17 ++ .../itemized/nc_claim_of_right_deduction.py | 16 +- .../gov/states/nc/tax/income/nc_income_tax.py | 8 +- ...nc_income_tax_before_refundable_credits.py | 15 ++ .../nc/tax/income/nc_refundable_credits.py | 11 ++ 37 files changed, 1021 insertions(+), 61 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/claim_of_right/threshold.yaml create mode 100644 policyengine_us/parameters/gov/states/nc/tax/income/credits/refundable.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py create mode 100644 policyengine_us/variables/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax_before_refundable_credits.py create mode 100644 policyengine_us/variables/gov/states/nc/tax/income/nc_refundable_credits.py diff --git a/policyengine_us/parameters/gov/household/household_refundable_credits.yaml b/policyengine_us/parameters/gov/household/household_refundable_credits.yaml index 5938f3e3b76..1cf574d021c 100644 --- a/policyengine_us/parameters/gov/household/household_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/household/household_refundable_credits.yaml @@ -27,7 +27,7 @@ values: - mo_refundable_credits # Missouri. - ms_refundable_credits # Mississippi. - mt_refundable_credits # Montana. - # Skip NC which has no refundable credits. + - nc_refundable_credits # North Carolina. - nd_refundable_credits # North Dakota. - ne_refundable_credits # Nebraska. - nh_refundable_credits # New Hampshire. diff --git a/policyengine_us/parameters/gov/household/household_refundable_state_credits.yaml b/policyengine_us/parameters/gov/household/household_refundable_state_credits.yaml index 2fe90dbccf3..4bdde27f54f 100644 --- a/policyengine_us/parameters/gov/household/household_refundable_state_credits.yaml +++ b/policyengine_us/parameters/gov/household/household_refundable_state_credits.yaml @@ -26,7 +26,7 @@ values: - mo_refundable_credits # Missouri. - ms_refundable_credits # Mississippi. - mt_refundable_credits # Montana. - # Skip NC which has no refundable credits. + - nc_refundable_credits # North Carolina. - nd_refundable_credits # North Dakota. - ne_refundable_credits # Nebraska. - nh_refundable_credits # New Hampshire. diff --git a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml index 656d4b40bf3..eb236bac70b 100644 --- a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml @@ -29,7 +29,7 @@ values: - mo_income_tax_before_refundable_credits - ms_income_tax_before_credits_unit - mt_income_tax_before_refundable_credits_unit - - nc_income_tax # NC has no refundable credits. + - nc_income_tax_before_refundable_credits - nd_income_tax_before_refundable_credits - ne_income_tax_before_refundable_credits - nh_income_tax_before_refundable_credits diff --git a/policyengine_us/parameters/gov/irs/credits/refundable.yaml b/policyengine_us/parameters/gov/irs/credits/refundable.yaml index 7676cc12363..1f4a2415d29 100644 --- a/policyengine_us/parameters/gov/irs/credits/refundable.yaml +++ b/policyengine_us/parameters/gov/irs/credits/refundable.yaml @@ -6,6 +6,7 @@ values: - refundable_ctc - recovery_rebate_credit - refundable_payroll_tax_credit + - claim_of_right_credit # Currently, the premium tax credit is not included in the refundable tax credits. # Add CDCC in 2021. 2021-01-01: @@ -14,6 +15,7 @@ values: - refundable_ctc - recovery_rebate_credit - refundable_payroll_tax_credit + - claim_of_right_credit # Currently, the premium tax credit is not included in the refundable tax credits. - cdcc # Remove CDCC in 2022. @@ -23,6 +25,7 @@ values: - refundable_ctc - recovery_rebate_credit - refundable_payroll_tax_credit + - claim_of_right_credit # Currently, the premium tax credit is not included in the refundable tax credits. metadata: unit: list diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/claim_of_right/threshold.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/claim_of_right/threshold.yaml new file mode 100644 index 00000000000..ec07a3987f1 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/claim_of_right/threshold.yaml @@ -0,0 +1,16 @@ +description: The IRS computes tax for the year of repayment as the lesser of the tax with a deduction for the repayment and the tax without it less the resulting decrease in prior-year tax, when the repayment of income held under a claim of right exceeds this amount. +values: + 1954-01-01: 3_000 +metadata: + unit: currency-USD + period: year + label: Claim of right repayment threshold + reference: + - title: 26 U.S. Code § 1341(a)(3) + href: https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm + - title: 26 U.S. Code § 67(b)(9) + href: https://www.law.cornell.edu/uscode/text/26/67#b_9 + - title: 26 CFR 1.1341-1(a)(3) + href: https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFR4c74bc8cf7c11fb/section-1.1341-1 + - title: "Publication 525 (2025), Repayments #page 36-37" + href: https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36 diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized_deductions.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized_deductions.yaml index 14d369f6999..d0dc9c77b9c 100644 --- a/policyengine_us/parameters/gov/irs/deductions/itemized_deductions.yaml +++ b/policyengine_us/parameters/gov/irs/deductions/itemized_deductions.yaml @@ -7,6 +7,7 @@ values: - medical_expense_deduction - casualty_loss_deduction - misc_deduction + - claim_of_right_deduction 2020-01-01: - charitable_deduction - charitable_deduction_for_non_itemizers @@ -15,6 +16,7 @@ values: - medical_expense_deduction - casualty_loss_deduction - misc_deduction + - claim_of_right_deduction 2022-01-01: - charitable_deduction - interest_deduction @@ -22,6 +24,7 @@ values: - medical_expense_deduction - casualty_loss_deduction - misc_deduction + - claim_of_right_deduction metadata: unit: list label: itemized deductions diff --git a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml index c3877dbb5d1..612fc062e93 100644 --- a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml @@ -27,7 +27,7 @@ values: - mo_income_tax_before_refundable_credits - ms_income_tax_before_credits_unit - mt_income_tax_before_refundable_credits_unit - - nc_income_tax + - nc_income_tax_before_refundable_credits - nd_income_tax_before_refundable_credits - ne_income_tax_before_refundable_credits - nh_income_tax_before_refundable_credits diff --git a/policyengine_us/parameters/gov/states/household/state_refundable_credits.yaml b/policyengine_us/parameters/gov/states/household/state_refundable_credits.yaml index 75c3f95eee0..4dc2984441f 100644 --- a/policyengine_us/parameters/gov/states/household/state_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/states/household/state_refundable_credits.yaml @@ -26,7 +26,7 @@ values: - mo_refundable_credits # Missouri. - ms_refundable_credits # Mississippi. - mt_refundable_credits # Montana. - # Skip NC which has no refundable credits. + - nc_refundable_credits # North Carolina. - nd_refundable_credits # North Dakota. - ne_refundable_credits # Nebraska. - nh_refundable_credits # New Hampshire. diff --git a/policyengine_us/parameters/gov/states/nc/tax/income/credits/refundable.yaml b/policyengine_us/parameters/gov/states/nc/tax/income/credits/refundable.yaml new file mode 100644 index 00000000000..8d654cd21be --- /dev/null +++ b/policyengine_us/parameters/gov/states/nc/tax/income/credits/refundable.yaml @@ -0,0 +1,13 @@ +description: North Carolina provides these refundable income tax credits and payments. +values: + 1995-01-01: + - nc_claim_of_right_payment +metadata: + unit: list + period: year + label: North Carolina refundable income tax credits + reference: + - title: N.C. Gen. Stat. § 105-266.2 + href: https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-266.2.html + - title: S.L. 1997-213, Section 2 (effective for taxable years beginning on or after January 1, 1995) + href: https://www.ncleg.gov/EnactedLegislation/SessionLaws/HTML/1997-1998/SL1997-213.html diff --git a/policyengine_us/reforms/states/nc/cdcc/nc_cdcc_reform.py b/policyengine_us/reforms/states/nc/cdcc/nc_cdcc_reform.py index 94e4efaf931..5ef1728228b 100644 --- a/policyengine_us/reforms/states/nc/cdcc/nc_cdcc_reform.py +++ b/policyengine_us/reforms/states/nc/cdcc/nc_cdcc_reform.py @@ -23,41 +23,35 @@ class nc_refundable_credits(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NC + # Baseline computes this via `adds`; replacing it with a + # formula requires clearing the inherited computation mode + # (the core engine rejects `formula` + `adds`/`subtracts`). + adds = None + subtracts = None def formula(tax_unit, period, parameters): + # Preserve the baseline refundable credits and ADD the new + # CDCC (see #8775). Baseline nc_income_tax subtracts + # nc_refundable_credits and excludes nc_use_tax, which + # state_use_tax already counts, so no override of it is needed. + baseline_credits = parameters( + period + ).gov.states.nc.tax.income.credits.refundable + total = add(tax_unit, period, list(baseline_credits)) + tax_unit( + "nc_cdcc", period + ) # Stack with nc_eitc when the NC EITC contrib reform is also # active, so enabling both NC contrib credits sums rather than # overwrites. - total = tax_unit("nc_cdcc", period) variables = tax_unit.simulation.tax_benefit_system.variables if "nc_eitc" in variables: total = total + tax_unit("nc_eitc", period) return total - class nc_income_tax(Variable): - value_type = float - entity = TaxUnit - label = "North Carolina income tax" - unit = USD - definition_period = YEAR - defined_for = StateCode.NC - - def formula(tax_unit, period, parameters): - tax_before_credits = add( - tax_unit, period, ["nc_income_tax_before_credits", "nc_use_tax"] - ) - non_refundable_credits = tax_unit("nc_non_refundable_credits", period) - tax_before_refundable = max_(0, tax_before_credits - non_refundable_credits) - - refundable_credits = tax_unit("nc_refundable_credits", period) - - return tax_before_refundable - refundable_credits - class reform(Reform): def apply(self): self.update_variable(nc_cdcc) self.update_variable(nc_refundable_credits) - self.update_variable(nc_income_tax) return reform diff --git a/policyengine_us/reforms/states/nc/eitc/nc_eitc_reform.py b/policyengine_us/reforms/states/nc/eitc/nc_eitc_reform.py index c483b1dea26..31eff7aba40 100644 --- a/policyengine_us/reforms/states/nc/eitc/nc_eitc_reform.py +++ b/policyengine_us/reforms/states/nc/eitc/nc_eitc_reform.py @@ -23,35 +23,28 @@ class nc_refundable_credits(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NC + # Baseline computes this via `adds`; replacing it with a + # formula requires clearing the inherited computation mode + # (the core engine rejects `formula` + `adds`/`subtracts`). + adds = None + subtracts = None def formula(tax_unit, period, parameters): - return tax_unit("nc_eitc", period) - - class nc_income_tax(Variable): - value_type = float - entity = TaxUnit - label = "North Carolina income tax" - unit = USD - definition_period = YEAR - defined_for = StateCode.NC - - def formula(tax_unit, period, parameters): - # Mirror baseline nc_income_tax, which uses - # nc_income_tax_before_credits and does NOT add nc_use_tax. This - # reform's only change is subtracting the new refundable EITC; - # adding use tax here would raise tax for every filer (even those - # with no EITC). See #8775. - tax_before_credits = tax_unit("nc_income_tax_before_credits", period) - non_refundable_credits = tax_unit("nc_non_refundable_credits", period) - tax_before_refundable = max_(0, tax_before_credits - non_refundable_credits) - refundable_credits = tax_unit("nc_refundable_credits", period) - return tax_before_refundable - refundable_credits + # Preserve the baseline refundable credits and ADD the new + # EITC. Baseline nc_income_tax subtracts nc_refundable_credits + # and excludes nc_use_tax, so no override of it is needed. + # See #8775. + baseline_credits = parameters( + period + ).gov.states.nc.tax.income.credits.refundable + return add(tax_unit, period, list(baseline_credits)) + tax_unit( + "nc_eitc", period + ) class reform(Reform): def apply(self): self.update_variable(nc_eitc) self.update_variable(nc_refundable_credits) - self.update_variable(nc_income_tax) return reform diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit.yaml new file mode 100644 index 00000000000..511377a29dc --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit.yaml @@ -0,0 +1,26 @@ +- name: Case 1, the credit is the prior-year decrease when tax is computed under section 1341(a)(5). + period: 2025 + input: + claim_of_right_section_1341_eligible: true + claim_of_right_credit_applies: true + claim_of_right_prior_year_tax_decrease: 1_234 + output: + claim_of_right_credit: 1_234 + +- name: Case 2, no credit under section 1341(a)(4). + period: 2025 + input: + claim_of_right_section_1341_eligible: true + claim_of_right_credit_applies: false + claim_of_right_prior_year_tax_decrease: 1_234 + output: + claim_of_right_credit: 0 + +- name: Case 3, no credit without a qualifying repayment. + period: 2025 + input: + claim_of_right_section_1341_eligible: false + claim_of_right_credit_applies: true + claim_of_right_prior_year_tax_decrease: 1_234 + output: + claim_of_right_credit: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml new file mode 100644 index 00000000000..2616941594a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml @@ -0,0 +1,75 @@ +# 26 CFR 1.1341-1(b)(3): when the two computations give the same tax, tax is +# computed under 1341(a)(4), with the deduction. The household is +# Publication 525 (2025) Example 40: $5,155.50 with the deduction and $5,903 +# without it. +- name: Case 1, equal tax under both methods keeps the deduction. + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 5_000 + # $5,903 - $747.50 = $5,155.50, the tax with the deduction. + claim_of_right_prior_year_tax_decrease: 747.5 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + income_tax_if_claiming_claim_of_right_credit: 5_155.5 + income_tax_if_claiming_claim_of_right_deduction: 5_155.5 + claim_of_right_credit_applies: false + income_tax: 5_155.5 + +- name: Case 2, a prior-year decrease 50 cents larger makes the credit cheaper. + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 748 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: true + claim_of_right_deduction: 0 + claim_of_right_credit: 748 + income_tax: 5_155 + +- name: Case 3, a repayment of $3,000 cannot use section 1341. + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 3_000 + claim_of_right_prior_year_tax_decrease: 2_000 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: false + claim_of_right_deduction: 0 + claim_of_right_credit: 0 + # Section 67(g) disallows the miscellaneous deduction, so tax is + # unchanged at the tax on $49,950. + income_tax: 5_903 + +- name: Case 4, with no prior-year decrease the deduction applies. + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + # $3,001 is the smallest whole-dollar repayment that qualifies. + claim_of_right_repayment: 3_001 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: false + claim_of_right_deduction: 3_001 + # $65,700 - $15,750 - $3,001 = $46,949; $1,192.50 + 12% x $35,024. + taxable_income: 46_949 + income_tax: 5_395.38 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.yaml new file mode 100644 index 00000000000..701c7120800 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.yaml @@ -0,0 +1,51 @@ +# 26 U.S.C. 1341(a)(3): the deduction must exceed $3,000. Publication 525 +# (2025), PDF page 36: "consider the total amount being repaid on the +# return. Each instance of repayment isn't considered separately." +- name: Case 1, a repayment of exactly $3,000 does not qualify. + period: 2025 + input: + tax_unit_claim_of_right_repayment: 3_000 + output: + claim_of_right_section_1341_eligible: false + +- name: Case 2, a repayment of $3,000.01 qualifies. + period: 2025 + input: + tax_unit_claim_of_right_repayment: 3_000.01 + output: + claim_of_right_section_1341_eligible: true + +- name: Case 3, no repayment. + period: 2025 + input: + tax_unit_claim_of_right_repayment: 0 + output: + claim_of_right_section_1341_eligible: false + +- name: Case 4, two repayments of $2,000 on a joint return qualify together. + period: 2025 + input: + people: + person1: + age: 45 + claim_of_right_repayment: 2_000 + person2: + age: 44 + claim_of_right_repayment: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + tax_unit_claim_of_right_repayment: 4_000 + claim_of_right_section_1341_eligible: true + +- name: Case 5, the $3,000 threshold applied before 2018 too. + period: 2017 + input: + tax_unit_claim_of_right_repayment: 3_001 + output: + claim_of_right_section_1341_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml new file mode 100644 index 00000000000..b5b2c47bba6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml @@ -0,0 +1,161 @@ +# Publication 525, Repayments, Example 40, in three editions. A single filer +# repaid $5,000 of income included in the prior year's income under a claim +# of right. Taxable income is $49,950 without the deduction and $44,950 with +# it; excluding the $5,000 cut the prior year's tax by $568 (2025, 2024) or +# $750 (2018). In each edition method 1, the deduction, gives less tax. +# Wages and charitable gifts are chosen to reproduce those taxable incomes: +# without the repayment, itemized deductions equal the standard deduction. +# Publication 525 figures tax with the Tax Table in 2024 and 2018 (the +# midpoint of each $50 row); PolicyEngine applies the rate schedule. + +- name: Case 1, Publication 525 (2025) Example 40, the deduction gives less tax. + # https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36 + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 568 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_section_1341_eligible: true + # Method 1: tax on $44,950 = $1,192.50 + 12% x ($44,950 - $11,925). + # Publication 525 rounds $5,155.50 to $5,156. + income_tax_if_claiming_claim_of_right_deduction: 5_155.5 + # Method 2: tax on $49,950, $5,903, less the $568 prior-year decrease. + income_tax_if_claiming_claim_of_right_credit: 5_335 + claim_of_right_credit_applies: false + claim_of_right_deduction: 5_000 + claim_of_right_credit: 0 + taxable_income: 44_950 + income_tax: 5_155.5 + +- name: Case 2, Publication 525 (2024) Example 40, the deduction gives less tax. + # https://www.irs.gov/pub/irs-prior/p525--2024.pdf#page=37 + period: 2024 + input: + age: 40 + employment_income: 64_550 + charitable_cash_donations: 14_600 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 568 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + # $1,160 + 12% x ($44,950 - $11,600) = $5,162; the Tax Table gives + # $5,165. + income_tax_if_claiming_claim_of_right_deduction: 5_162 + # $1,160 + $4,266 + 22% x ($49,950 - $47,150) = $6,042 (Tax Table + # $6,048), less $568 = $5,474 (Publication 525: $5,480). + income_tax_if_claiming_claim_of_right_credit: 5_474 + claim_of_right_credit_applies: false + claim_of_right_deduction: 5_000 + taxable_income: 44_950 + income_tax: 5_162 + +- name: Case 3, Publication 525 (2018) Example 40, the deduction gives less tax. + # https://www.irs.gov/pub/irs-prior/p525--2018.pdf#page=34 + period: 2018 + input: + age: 40 + employment_income: 61_950 + charitable_cash_donations: 12_000 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 750 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + # $952.50 + $3,501 + 22% x ($44,950 - $38,700) = $5,828.50 (Tax Table + # $5,834). + income_tax_if_claiming_claim_of_right_deduction: 5_828.5 + # $4,453.50 + 22% x ($49,950 - $38,700) = $6,928.50 (Tax Table $6,934), + # less $750 = $6,178.50 (Publication 525: $6,184). + income_tax_if_claiming_claim_of_right_credit: 6_178.5 + claim_of_right_credit_applies: false + claim_of_right_deduction: 5_000 + taxable_income: 44_950 + income_tax: 5_828.5 + +- name: Case 4, a filer who takes the standard deduction gains only from the credit. + # Publication 525 (2025) method 2 steps: tax without the deduction, less + # the decrease in the earlier year's tax. + period: 2025 + input: + age: 40 + employment_income: 40_000 + claim_of_right_repayment: 10_000 + claim_of_right_prior_year_tax_decrease: 2_200 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + # Itemizing $10,000 loses to the $15,750 standard deduction, so the + # deduction leaves tax on $24,250 at $1,192.50 + 12% x $12,325. + income_tax_if_claiming_claim_of_right_deduction: 2_671.5 + income_tax_if_claiming_claim_of_right_credit: 471.5 + claim_of_right_credit_applies: true + claim_of_right_deduction: 0 + claim_of_right_credit: 2_200 + taxable_income: 24_250 + income_tax: 471.5 + +- name: Case 5, a prior-year decrease above the year's tax is refunded. + # 26 U.S.C. 1341(b)(1): the excess is considered a payment of tax. + period: 2025 + input: + age: 40 + employment_income: 40_000 + claim_of_right_repayment: 10_000 + claim_of_right_prior_year_tax_decrease: 3_000 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: true + claim_of_right_credit: 3_000 + income_tax_before_refundable_credits: 2_671.5 + income_tax: -328.5 + +- name: Case 6, a joint return counts the spouses' repayments and decreases, not a dependent's. + period: 2025 + input: + people: + person1: + age: 45 + employment_income: 90_000 + claim_of_right_repayment: 2_000 + claim_of_right_prior_year_tax_decrease: 300 + person2: + age: 44 + claim_of_right_repayment: 2_000 + claim_of_right_prior_year_tax_decrease: 200 + person3: + age: 30 + is_tax_unit_dependent: true + claim_of_right_repayment: 9_000 + claim_of_right_prior_year_tax_decrease: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + state_sales_tax: 0 + local_sales_tax: 0 + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + tax_unit_claim_of_right_repayment: 4_000 + claim_of_right_section_1341_eligible: true + # Taxable income $90,000 - $31,500 = $58,500; tax $2,385 + 12% x + # $34,650 = $6,543, less the $500 credit for other dependents. The + # $4,000 deduction loses to the standard deduction. + income_tax_if_claiming_claim_of_right_deduction: 6_043 + income_tax_if_claiming_claim_of_right_credit: 5_543 + claim_of_right_credit_applies: true + claim_of_right_credit: 500 + income_tax: 5_543 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.yaml new file mode 100644 index 00000000000..20e1e01985c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.yaml @@ -0,0 +1,40 @@ +- name: Case 1, the head's and spouse's repayments add up. + period: 2025 + input: + people: + person1: + age: 45 + claim_of_right_repayment: 2_000 + person2: + age: 44 + claim_of_right_repayment: 1_500 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + tax_unit_claim_of_right_repayment: 3_500 + +- name: Case 2, a dependent's repayment belongs on the dependent's own return. + period: 2025 + input: + people: + person1: + age: 45 + claim_of_right_repayment: 1_000 + person2: + age: 20 + is_tax_unit_dependent: true + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + tax_unit_claim_of_right_repayment: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml new file mode 100644 index 00000000000..feac0b40e12 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml @@ -0,0 +1,66 @@ +- name: Case 1, the whole repayment is deductible under section 1341(a)(4). + period: 2025 + input: + tax_unit_claim_of_right_repayment: 8_000 + claim_of_right_section_1341_eligible: true + claim_of_right_credit_applies: false + output: + claim_of_right_deduction: 8_000 + +- name: Case 2, no deduction under section 1341(a)(5). + # 26 U.S.C. 1341(b)(3). + period: 2025 + input: + tax_unit_claim_of_right_repayment: 8_000 + claim_of_right_section_1341_eligible: true + claim_of_right_credit_applies: true + output: + claim_of_right_deduction: 0 + +- name: Case 3, a repayment of $3,000 or less is not a section 1341 deduction. + period: 2025 + input: + tax_unit_claim_of_right_repayment: 3_000 + claim_of_right_section_1341_eligible: false + claim_of_right_credit_applies: false + output: + claim_of_right_deduction: 0 + +- name: Case 4, the deduction is subject to the overall limitation on itemized deductions before 2018. + # 2017 Schedule A instructions, Itemized Deductions Worksheet, PDF page 14: + # line 2 removes only gambling and casualty or theft losses on line 28, so + # the line 28 claim of right deduction stays in the limited base. + period: 2017 + input: + age: 40 + employment_income: 400_000 + claim_of_right_repayment: 50_000 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_deduction: 50_000 + total_itemized_taxable_income_deductions: 50_000 + # min(3% x ($400,000 - $261,500), 80% x $50,000) = $4,155. + itemized_taxable_income_deductions_reduction: 4_155 + itemized_taxable_income_deductions: 45_845 + +- name: Case 5, the deduction is subject to the limitation on itemized deductions from 2026. + # 26 U.S.C. 68 as amended by P.L. 119-21, sec. 70111: 2/37 of the lesser + # of itemized deductions and taxable income plus itemized deductions above + # the $640,600 start of the 37% bracket for single filers in 2026. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 1_000_000 + claim_of_right_repayment: 100_000 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_deduction: 100_000 + total_itemized_taxable_income_deductions: 100_000 + # 2/37 x min($100,000, $1,000,000 - $640,600) = $5,405.41. + itemized_taxable_income_deductions_reduction: 5_405.41 + itemized_taxable_income_deductions: 94_594.59 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml new file mode 100644 index 00000000000..d2f4445cdc2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml @@ -0,0 +1,54 @@ +# N.C. Gen. Stat. 105-266.2: a filer subject to section 1341(a)(5) is +# considered to have paid North Carolina tax equal to the increase in the +# earlier year's North Carolina tax from including the restored item. +- name: Case 1, the payment is the prior-year North Carolina tax increase under section 1341(a)(5). + period: 2025 + input: + claim_of_right_credit_applies: true + nc_claim_of_right_prior_year_tax_increase: 300 + state_code: NC + output: + nc_claim_of_right_payment: 300 + +- name: Case 2, no payment under section 1341(a)(4). + period: 2025 + input: + claim_of_right_credit_applies: false + nc_claim_of_right_prior_year_tax_increase: 300 + state_code: NC + output: + nc_claim_of_right_payment: 0 + +- name: Case 3, a joint return sums the spouses' increases, not a dependent's. + period: 2025 + input: + people: + person1: + age: 45 + nc_claim_of_right_prior_year_tax_increase: 200 + person2: + age: 44 + nc_claim_of_right_prior_year_tax_increase: 100 + person3: + age: 30 + is_tax_unit_dependent: true + nc_claim_of_right_prior_year_tax_increase: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + claim_of_right_credit_applies: true + households: + household: + members: [person1, person2, person3] + state_code: NC + output: + nc_claim_of_right_payment: 300 + +- name: Case 4, no payment outside North Carolina. + period: 2025 + input: + claim_of_right_credit_applies: true + nc_claim_of_right_prior_year_tax_increase: 300 + state_code: SC + output: + nc_claim_of_right_payment: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml index b0eafe19c2f..e389edb9a97 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml @@ -369,3 +369,101 @@ nc_taxable_income: 63_000 # 63,000 * 4.25% = 2,677.50 (2,858.13 without the repayment). nc_income_tax: 2_677.50 + +- name: No deduction when federal tax is computed under section 1341(a)(5). + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 80_000 + claim_of_right_credit_applies: true + households: + household: + members: [head] + state_code: NC + output: + nc_claim_of_right_deduction: 0 + +- name: The deduction is allowed when federal tax is computed under section 1341(a)(4). + period: 2025 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 80_000 + claim_of_right_credit_applies: false + households: + household: + members: [head] + state_code: NC + output: + nc_claim_of_right_deduction: 5_000 + +- name: A filer the federal credit method suits loses the North Carolina deduction and is treated as paying the prior-year North Carolina tax on the item. + # The household above, now with a $1,100 decrease in 2024 federal tax and + # a $212.50 increase in 2024 North Carolina tax from the repaid $5,000. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 80_000 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 1_100 + nc_claim_of_right_prior_year_tax_increase: 212.5 + real_estate_taxes: 4_000 + tax_units: + tax_unit: + members: [head] + charitable_deduction: 8_000 + households: + household: + members: [head] + state_code: NC + output: + claim_of_right_credit_applies: true + claim_of_right_credit: 1_100 + nc_claim_of_right_deduction: 0 + # 4,000 + 8,000 = 12,000 loses to the 12,750 standard deduction. + nc_standard_or_itemized_deductions: 12_750 + nc_taxable_income: 67_250 + # 67,250 * 4.25% = 2,858.13, less the 212.50 payment. + nc_income_tax_before_refundable_credits: 2_858.13 + nc_claim_of_right_payment: 212.5 + nc_income_tax: 2_645.63 + +- name: A filer the federal deduction method suits keeps the North Carolina deduction and gets no payment. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 80_000 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 568 + nc_claim_of_right_prior_year_tax_increase: 212.5 + real_estate_taxes: 4_000 + tax_units: + tax_unit: + members: [head] + charitable_deduction: 8_000 + households: + household: + members: [head] + state_code: NC + output: + claim_of_right_credit_applies: false + nc_claim_of_right_deduction: 5_000 + nc_claim_of_right_payment: 0 + nc_income_tax: 2_677.50 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/nc_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/nc_income_tax.yaml index 6957ae44fac..02a5fb29344 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/nc_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/nc_income_tax.yaml @@ -27,3 +27,15 @@ state_code: NC output: nc_income_tax: 0 + +- name: The refundable claim of right payment can make North Carolina income tax negative + period: 2025 + input: + nc_income_tax_before_credits: 100 + nc_non_refundable_credits: 30 + nc_claim_of_right_payment: 500 + state_code: NC + output: + nc_income_tax_before_refundable_credits: 70 + nc_refundable_credits: 500 + nc_income_tax: -430 diff --git a/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml b/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml index 9dab37908b0..e0e8d6353f7 100644 --- a/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml +++ b/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml @@ -81,3 +81,37 @@ cdcc_potential: 1_000 output: nc_cdcc: 0 + +- name: NC CDCC reform keeps the baseline claim of right payment + # Regression test for #8775: the reform adds its CDCC to North Carolina's + # baseline refundable credits instead of replacing them. + period: 2026 + reforms: policyengine_us.reforms.states.nc.cdcc.nc_cdcc + input: + gov.contrib.states.nc.cdcc.in_effect: true + state_code: NC + cdcc_potential: 1_000 + nc_claim_of_right_payment: 250 + nc_income_tax_before_credits: 600 + nc_use_tax: 0 + nc_non_refundable_credits: 0 + output: + nc_cdcc: 300 + nc_refundable_credits: 550 + nc_income_tax: 50 + +- name: NC CDCC reform does not add use tax to income tax + # Use tax is counted at the household level through state_use_tax (#8775). + period: 2026 + reforms: policyengine_us.reforms.states.nc.cdcc.nc_cdcc + input: + gov.contrib.states.nc.cdcc.in_effect: true + state_code: NC + cdcc_potential: 1_000 + nc_income_tax_before_credits: 600 + nc_use_tax: 100 + nc_non_refundable_credits: 0 + output: + nc_cdcc: 300 + # 600 - 300 = 300; it would be 400 if use tax were added. + nc_income_tax: 300 diff --git a/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc.yaml b/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc.yaml index ed383cb202b..09145e7375a 100644 --- a/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc.yaml @@ -49,3 +49,21 @@ nc_eitc: 3_000 nc_refundable_credits: 3_000 nc_income_tax: -2_500 + +- name: NC EITC reform keeps the baseline claim of right payment + # Regression test for #8775: the reform adds its EITC to North Carolina's + # baseline refundable credits instead of replacing them. + period: 2026 + reforms: policyengine_us.reforms.states.nc.eitc.nc_eitc + input: + gov.contrib.states.nc.eitc.match: 0.1 + state_code: NC + eitc: 1_000 + nc_claim_of_right_payment: 250 + nc_income_tax_before_credits: 600 + nc_use_tax: 0 + nc_non_refundable_credits: 0 + output: + nc_eitc: 100 + nc_refundable_credits: 350 + nc_income_tax: 250 diff --git a/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc_excludes_use_tax.yaml b/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc_excludes_use_tax.yaml index bf877dda502..4abc4d48e43 100644 --- a/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc_excludes_use_tax.yaml +++ b/policyengine_us/tests/policy/contrib/states/nc/eitc/nc_eitc_excludes_use_tax.yaml @@ -1,5 +1,5 @@ -# Regression test for #8775: the NC EITC reform's nc_income_tax override must -# mirror baseline (nc_income_tax_before_credits minus credits) and must NOT add +# Regression test for #8775: under the NC EITC reform, nc_income_tax must +# match baseline (nc_income_tax_before_credits minus credits) and must NOT add # nc_use_tax. Use tax is already counted at the household level via # state_use_tax; adding it here would raise tax for every NC filer (even with # no EITC) and double-count it. diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py new file mode 100644 index 00000000000..dc819e23b2a --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class claim_of_right_credit(Variable): + value_type = float + entity = TaxUnit + label = "Section 1341 credit for repayment of amounts included in income from earlier years" + unit = USD + documentation = ( + "When tax is computed under section 1341(a)(5), the decrease in " + "prior-year tax from excluding the repaid income. It is reported as " + "a payment (Schedule 3, line 13b), so any excess over the year's tax " + "is refunded (section 1341(b)(1))." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + "https://www.irs.gov/pub/irs-prior/f1040s3--2025.pdf", + # 2025 Form 1040 instructions, Schedule 3 line 13b + "https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf#page=117", + # Publication 525 (2025), Repayments, method 2 + "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", + ) + + def formula(tax_unit, period, parameters): + eligible = tax_unit("claim_of_right_section_1341_eligible", period) + credit_applies = tax_unit("claim_of_right_credit_applies", period) + person = tax_unit.members + decrease = person("claim_of_right_prior_year_tax_decrease", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + prior_year_tax_decrease = tax_unit.sum(decrease * head_or_spouse) + return where(eligible & credit_applies, prior_year_tax_decrease, 0) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py new file mode 100644 index 00000000000..4af8870157f --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py @@ -0,0 +1,39 @@ +from policyengine_us.model_api import * + + +class claim_of_right_credit_applies(Variable): + value_type = bool + entity = TaxUnit + label = "Tax is computed under section 1341(a)(5)" + documentation = ( + "Whether federal income tax for the year of repayment is computed " + "under 26 U.S.C. 1341(a)(5): the tax without a deduction for the " + "repayment, minus the decrease in prior-year tax from excluding the " + "repaid income (the credit method). Section 1341 makes the tax the " + "lesser of this and the tax computed with the deduction under " + "1341(a)(4), so the method is computed, not elected. When the two " + "are equal, the tax is computed under 1341(a)(4)." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + # 26 CFR 1.1341-1(b)(1) and (b)(3) + "https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFR4c74bc8cf7c11fb/section-1.1341-1", + # Publication 525 (2025), Repayments, methods 1 and 2 + "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", + ) + + def formula(tax_unit, period, parameters): + eligible = tax_unit("claim_of_right_section_1341_eligible", period) + if not eligible.any(): + # Comparing the two methods takes two more tax computations; + # skip them when no tax unit repays more than the threshold. + return np.zeros(tax_unit.count, dtype=bool) + tax_if_credit = tax_unit("income_tax_if_claiming_claim_of_right_credit", period) + tax_if_deduction = tax_unit( + "income_tax_if_claiming_claim_of_right_deduction", period + ) + # Ties go to the deduction (26 CFR 1.1341-1(b)(3)); the tolerance + # keeps floating-point noise from deciding a tie. + TOLERANCE = 0.01 + return eligible & (tax_if_credit < tax_if_deduction - TOLERANCE) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py new file mode 100644 index 00000000000..5c56be6e87f --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class claim_of_right_prior_year_tax_decrease(Variable): + value_type = float + entity = Person + label = "Prior-year tax decrease from excluding repaid claim of right income" + unit = USD + documentation = ( + "Decrease in federal income tax for the earlier year or years that " + "would result solely from excluding from gross income the amount " + "repaid this year (claim_of_right_repayment), net of any increase " + "in minimum tax: the tax shown on the earlier return less the tax " + "refigured without the repaid income. The tax unit uses the sum for " + "its head and spouse." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + "https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFR4c74bc8cf7c11fb/section-1.1341-1", + # Publication 525 (2025), Repayments, method 2, steps 2 and 3 + "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", + ) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.py new file mode 100644 index 00000000000..14de55d9256 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_section_1341_eligible.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class claim_of_right_section_1341_eligible(Variable): + value_type = bool + entity = TaxUnit + label = "Claim of right repayment qualifies for section 1341" + documentation = ( + "Whether the repayment of claim of right income on the return " + "exceeds the section 1341(a)(3) threshold, so that tax is the " + "lesser of the tax with a deduction for the repayment and the tax " + "without it less the prior-year tax decrease. The threshold applies " + "to the total repaid on the return, not to each repayment." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + "https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFR4c74bc8cf7c11fb/section-1.1341-1", + "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", + ) + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.irs.deductions.itemized.claim_of_right + repayment = tax_unit("tax_unit_claim_of_right_repayment", period) + return repayment > p.threshold diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py new file mode 100644 index 00000000000..b7ece65bc04 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class income_tax_if_claiming_claim_of_right_credit(Variable): + value_type = float + entity = TaxUnit + label = "Federal income tax under section 1341(a)(5)" + unit = USD + documentation = ( + "Federal income tax computed without a deduction for the repayment of " + "claim of right income, less the decrease in prior-year tax from " + "excluding the repaid income (section 1341(a)(5))." + ) + definition_period = YEAR + reference = "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm" + + def formula(tax_unit, period, parameters): + branch = get_override_branch( + tax_unit.simulation, + "claim_of_right_credit", + period, + {"claim_of_right_credit_applies": np.ones(tax_unit.count, dtype=bool)}, + ) + return branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py new file mode 100644 index 00000000000..802f7e1b307 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class income_tax_if_claiming_claim_of_right_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Federal income tax under section 1341(a)(4)" + unit = USD + documentation = ( + "Federal income tax computed with a deduction for the repayment of " + "claim of right income (section 1341(a)(4))." + ) + definition_period = YEAR + reference = "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm" + + def formula(tax_unit, period, parameters): + branch = get_override_branch( + tax_unit.simulation, + "claim_of_right_deduction", + period, + {"claim_of_right_credit_applies": np.zeros(tax_unit.count, dtype=bool)}, + ) + return branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.py new file mode 100644 index 00000000000..be284128fc5 --- /dev/null +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/tax_unit_claim_of_right_repayment.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class tax_unit_claim_of_right_repayment(Variable): + value_type = float + entity = TaxUnit + label = "Repayment of claim of right income on the return" + unit = USD + documentation = ( + "Total repaid this year by the head and spouse of income included in " + "an earlier year under a claim of right, excluding repayments " + "deducted in arriving at adjusted gross income. Dependents' " + "repayments belong on their own returns." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + # Publication 525 (2025), Repayments: consider the total amount + # repaid on the return + "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", + ) + + def formula(tax_unit, period, parameters): + person = tax_unit.members + repayment = person("claim_of_right_repayment", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + return tax_unit.sum(repayment * head_or_spouse) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py new file mode 100644 index 00000000000..b40f5a31ce4 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class claim_of_right_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Claim of right repayment deduction" + unit = USD + documentation = ( + "Itemized deduction for repaying more than the section 1341 threshold " + "of income included in an earlier year under a claim of right " + "(Schedule A, line 16), when tax is computed with the deduction " + "under section 1341(a)(4). It is not a miscellaneous itemized " + "deduction, so neither the two percent floor nor section 67(g) " + "applies. Under section 1341(a)(5) the repayment is not deducted " + "(section 1341(b)(3))." + ) + definition_period = YEAR + reference = ( + "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + "https://www.law.cornell.edu/uscode/text/26/67#b_9", + # 2025 Schedule A instructions, line 16, PDF pages 11-12 + "https://www.irs.gov/pub/irs-prior/i1040sca--2025.pdf#page=11", + # 2017 Schedule A instructions, line 28 + "https://www.irs.gov/pub/irs-prior/i1040sca--2017.pdf#page=13", + ) + + def formula(tax_unit, period, parameters): + eligible = tax_unit("claim_of_right_section_1341_eligible", period) + credit_applies = tax_unit("claim_of_right_credit_applies", period) + repayment = tax_unit("tax_unit_claim_of_right_repayment", period) + return where(eligible & ~credit_applies, repayment, 0) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py new file mode 100644 index 00000000000..46977319d71 --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class nc_claim_of_right_payment(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina tax payment for restored claim of right income" + unit = USD + documentation = ( + "A filer whose federal tax is computed under 26 U.S.C. 1341(a)(5) is " + "considered to have paid North Carolina tax equal to the increase in " + "the earlier year's North Carolina tax from including the restored " + "income, minus the decrease in this year's North Carolina tax because " + "the item was deductible. An overpayment it creates is refunded." + ) + definition_period = YEAR + reference = ( + "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-266.2.html", + "https://www.ncleg.gov/EnactedLegislation/SessionLaws/HTML/1997-1998/SL1997-213.html", + ) + defined_for = StateCode.NC + + def formula(tax_unit, period, parameters): + credit_applies = tax_unit("claim_of_right_credit_applies", period) + person = tax_unit.members + increase = person("nc_claim_of_right_prior_year_tax_increase", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + prior_year_tax_increase = tax_unit.sum(increase * head_or_spouse) + # Term (ii), the decrease in this year's North Carolina tax because + # the item was deductible, is zero: under section 1341(a)(5) the + # repayment is not deducted federally (section 1341(b)(3)), and + # G.S. 105-153.5(a)(2)d allows no North Carolina deduction for it. + return where(credit_applies, prior_year_tax_increase, 0) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py new file mode 100644 index 00000000000..075c3021eed --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class nc_claim_of_right_prior_year_tax_increase(Variable): + value_type = float + entity = Person + label = "North Carolina prior-year tax increase from restored claim of right income" + unit = USD + documentation = ( + "Amount by which North Carolina individual income tax for the earlier " + "year or years increased because the income repaid this year " + "(claim_of_right_repayment) was included in gross income for that " + "year. The tax unit uses the sum for its head and spouse." + ) + definition_period = YEAR + reference = "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-266.2.html" + defined_for = StateCode.NC diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py index dbcbe3397ee..c978d27021d 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.py @@ -12,10 +12,11 @@ class nc_claim_of_right_deduction(Variable): "2026, only income North Carolina taxed that year. Repayments above " "the threshold are deductible in full. Smaller repayments are reduced " "by two percent of federal adjusted gross income less the filers' " - "other miscellaneous itemized deductions. Not modeled: the bar on the " - "deduction when federal tax for the year of repayment is computed " - "under 26 U.S.C. 1341(a)(5), and from 2026, repayments of amounts North " - "Carolina added to adjusted gross income that federal law excluded." + "other miscellaneous itemized deductions. No deduction is allowed " + "when federal tax for the year of repayment is computed under 26 " + "U.S.C. 1341(a)(5). Not modeled: from 2026, repayments of amounts " + "North Carolina added to adjusted gross income that federal law " + "excluded." ) definition_period = YEAR reference = ( @@ -59,8 +60,13 @@ def formula(tax_unit, period, parameters): other_misc_deductions = 0 remaining_floor = max_(floor - other_misc_deductions, 0) small_repayment_deduction = max_(repayment - remaining_floor, 0) - return where( + deduction = where( repayment > p.claim_of_right.threshold, repayment, small_repayment_deduction, ) + # No deduction when federal tax for the year of repayment is + # computed under section 1341(a)(5); G.S. 105-266.2 instead treats + # the prior-year North Carolina tax on the item as a payment. + credit_applies = tax_unit("claim_of_right_credit_applies", period) + return where(credit_applies, 0, deduction) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax.py b/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax.py index 83a4c333d99..f42b325f956 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax.py @@ -8,9 +8,5 @@ class nc_income_tax(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NC - - def formula(tax_unit, period, parameters): - tax_before_credits = tax_unit("nc_income_tax_before_credits", period) - # North Carolina provides no refundable income tax credits - credits = tax_unit("nc_non_refundable_credits", period) - return max_(0, tax_before_credits - credits) + adds = ["nc_income_tax_before_refundable_credits"] + subtracts = ["nc_refundable_credits"] diff --git a/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax_before_refundable_credits.py b/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax_before_refundable_credits.py new file mode 100644 index 00000000000..92cabeac3b5 --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/nc_income_tax_before_refundable_credits.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * + + +class nc_income_tax_before_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina income tax before refundable credits" + unit = USD + definition_period = YEAR + defined_for = StateCode.NC + + def formula(tax_unit, period, parameters): + tax_before_credits = tax_unit("nc_income_tax_before_credits", period) + credits = tax_unit("nc_non_refundable_credits", period) + return max_(0, tax_before_credits - credits) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/nc_refundable_credits.py b/policyengine_us/variables/gov/states/nc/tax/income/nc_refundable_credits.py new file mode 100644 index 00000000000..8f6815920db --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/nc_refundable_credits.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class nc_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina refundable credits" + unit = USD + definition_period = YEAR + defined_for = StateCode.NC + adds = "gov.states.nc.tax.income.credits.refundable" From 4526aed52fd9214b80a65c444a9deef930885b65 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 20:23:57 -0400 Subject: [PATCH 04/11] Keep the federal claim of right deduction and credit out of state rules that exclude them (AZ, KS, WI, MO, OR, VA); property tests Co-Authored-By: Claude Opus 5.5 --- .../federal_income_tax/ignored_credits.yaml | 6 +- .../itemized/reduction/applicable.yaml | 4 + .../itemized/az_itemized_deductions.yaml | 16 + .../ks/tax/income/ks_itemized_deductions.yaml | 13 + .../mo_federal_income_tax_deduction.yaml | 17 + .../or_federal_tax_liability_subtraction.yaml | 15 + .../va_reduced_itemized_deductions.yaml | 17 + .../income/wi_itemized_deduction_credit.yaml | 14 + ..._claim_of_right_section_1341_properties.py | 295 ++++++++++++++++++ .../itemized/az_itemized_deductions.py | 6 + .../deductions/ks_itemized_deductions.py | 8 +- .../or_federal_tax_liability_subtraction.py | 9 +- .../wi_itemized_deduction_credit_potential.py | 11 +- 13 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py diff --git a/policyengine_us/parameters/gov/states/mo/tax/income/deductions/federal_income_tax/ignored_credits.yaml b/policyengine_us/parameters/gov/states/mo/tax/income/deductions/federal_income_tax/ignored_credits.yaml index f82c34178f1..e32c3b86053 100644 --- a/policyengine_us/parameters/gov/states/mo/tax/income/deductions/federal_income_tax/ignored_credits.yaml +++ b/policyengine_us/parameters/gov/states/mo/tax/income/deductions/federal_income_tax/ignored_credits.yaml @@ -4,23 +4,27 @@ description: Missouri ignores the following credits in federal income tax deduct # Part I Line 3, and the net premium tax credit (Schedule 3 Line 9). Credits # claimed on Line 28 (refundable CTC) and Line 30 (recovery rebate), and the # 2021 refundable CDCC (Schedule 3 Line 13g), never reduce Line 22, so they -# are added back here. The EIC is not added back: the Line 9 instructions say +# are added back here. So is the section 1341 credit for repaid claim of right +# income (Schedule 3 Line 13b). The EIC is not added back: the Line 9 instructions say # "If you have an earned income credit, you must subtract the credit from the # tax on your federal return." values: 2018-01-01: - recovery_rebate_credit - refundable_ctc + - claim_of_right_credit # The CDCC is a refundable credit only in 2021 (ARPA); in other years it # already reduces income tax before refundable credits, so adding it back # would overstate the base. 2021-01-01: - recovery_rebate_credit - refundable_ctc + - claim_of_right_credit - cdcc 2022-01-01: - recovery_rebate_credit - refundable_ctc + - claim_of_right_credit metadata: unit: list label: Missouri ignored credits in federal income tax deduction diff --git a/policyengine_us/parameters/gov/states/va/tax/income/deductions/itemized/reduction/applicable.yaml b/policyengine_us/parameters/gov/states/va/tax/income/deductions/itemized/reduction/applicable.yaml index 2c9e4102ca5..27407e3bc2e 100644 --- a/policyengine_us/parameters/gov/states/va/tax/income/deductions/itemized/reduction/applicable.yaml +++ b/policyengine_us/parameters/gov/states/va/tax/income/deductions/itemized/reduction/applicable.yaml @@ -7,11 +7,15 @@ values: - real_estate_taxes - casualty_loss_deduction - va_capped_state_and_local_sales_or_income_tax + # Schedule A line 16b, other itemized deductions. + - claim_of_right_deduction metadata: unit: list period: year label: Virginia itemized deductions reduction applicable deduction sources reference: + - title: "2025 Virginia Schedule A Itemized Deductions, line 16b and Limited Itemized Deduction Worksheet line 1 #page 1-2" + href: https://www.tax.virginia.gov/sites/default/files/taxforms/individual-income-tax/2025/schedule-2025.pdf#page=1 - title: 2024 Virginia Schedule A Itemized Deductions href: https://www.tax.virginia.gov/sites/default/files/taxforms/individual-income-tax/2024/schedule-2024.pdf#page=2 - title: 2023 Virginia Schedule A Itemized Deductions diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml index 620371ff617..f1f0c4dc025 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml @@ -126,3 +126,19 @@ output: # min($8,000, $10,000) = $8,000 az_itemized_deductions: 8_000 + +- name: The federal claim of right repayment deduction gives no Arizona deduction + # A.R.S. 43-1021(9) adds the section 1341(a)(4) deduction back to Arizona + # gross income; A.R.S. 43-1029 computes Arizona tax without it. + absolute_error_margin: 0.01 + period: 2025 + input: + state_code: AZ + interest_deduction: 6_000 + claim_of_right_deduction: 5_000 + salt_deduction: 0 + charitable_deduction: 0 + az_charitable_contributions_credit_potential: 0 + itemized_medical_expenses: 0 + output: + az_itemized_deductions: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ks/tax/income/ks_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ks/tax/income/ks_itemized_deductions.yaml index 63a6603f62b..bdcf4857084 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ks/tax/income/ks_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ks/tax/income/ks_itemized_deductions.yaml @@ -19,3 +19,16 @@ state_code: KS output: ks_itemized_deductions: 12 + 9 + 2 + +- name: KS itemized deductions leave out the federal claim of right repayment deduction + # K.S.A. 79-32,120(a) lists charitable contributions, medical care, + # qualified residence interest and property taxes only. + period: 2025 + input: + real_estate_taxes: 3_000 + interest_deduction: 9_000 + claim_of_right_deduction: 5_000 + state_code: KS + output: + itemized_deductions_less_salt: 14_000 + ks_itemized_deductions: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_federal_income_tax_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_federal_income_tax_deduction.yaml index b1a7ebea29c..e1e12da80db 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_federal_income_tax_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_federal_income_tax_deduction.yaml @@ -166,3 +166,20 @@ state_code: MO output: mo_federal_income_tax_deduction: 0 + +- name: The section 1341 credit does not reduce the tax on the federal return + # The MO-1040 Line 9 chart starts from Form 1040 Line 22 and does not + # subtract Schedule 3 Line 13b. Income tax of 340 is 1,840 on Line 22 less + # the 1,500 credit; 25% of 1,840 is 460. + period: 2025 + absolute_error_margin: 0.01 + input: + mo_adjusted_gross_income: 30_000 + income_tax: 340 + claim_of_right_credit: 1_500 + recovery_rebate_credit: 0 + refundable_ctc: 0 + filing_status: SINGLE + state_code: MO + output: + mo_federal_income_tax_deduction: 460 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.yaml index 4e8190d3f01..380a592755c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.yaml @@ -112,3 +112,18 @@ output: # 50% x 8,750 or_federal_tax_liability_subtraction: 4_375 + +- name: The section 1341 credit does not reduce the federal tax liability subtraction + # 2025 Publication OR-17, federal tax worksheet: Form 1040 line 22 less the + # American Opportunity, refundable adoption and premium tax credits only. + period: 2025 + input: + state_code: OR + filing_status: SINGLE + adjusted_gross_income: 60_000 + # 4,000 on Form 1040 line 22 less a 1,500 section 1341 credit. + income_tax: 2_500 + eitc: 0 + claim_of_right_credit: 1_500 + output: + or_federal_tax_liability_subtraction: 4_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/deductions/va_reduced_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/deductions/va_reduced_itemized_deductions.yaml index 80257f26b5c..2e42bfbd9c8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/deductions/va_reduced_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/deductions/va_reduced_itemized_deductions.yaml @@ -40,3 +40,20 @@ state_code: VA output: va_reduced_itemized_deductions: 35_282 + +- name: The federal claim of right repayment deduction stays in the limited itemized deduction total + # 2025 Schedule A line 16b is in line 1 of the Limited Itemized Deduction + # Worksheet and not in line 2. + period: 2025 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 500_000 # > $332,700 + charitable_deduction: 20_000 + claim_of_right_deduction: 10_000 + state_and_local_sales_or_income_tax: 0 + state_code: VA + output: + # Line 1: 30,000. Line 9: min(80% x 30,000, 3% x (500,000 - 332,700)) + # = 5,019. Line 12a: 30,000 - 5,019. + va_reduced_itemized_deductions: 24_981 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_itemized_deduction_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_itemized_deduction_credit.yaml index 38850b7f5cd..72cc1f65438 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_itemized_deduction_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_itemized_deduction_credit.yaml @@ -25,3 +25,17 @@ state_code: WI output: wi_itemized_deduction_credit_potential: 0 + +- name: WI itemized deduction credit leaves out the federal claim of right repayment deduction + # Wis. Stat. 71.07(5)(a)7; Schedule 1 has lines for medical expenses, + # interest, gifts to charity and casualty losses only. + absolute_error_margin: 0.01 + period: 2025 + input: + wi_standard_deduction: 10_000 + charitable_deduction: 3_000 + interest_deduction: 12_000 + claim_of_right_deduction: 20_000 + state_code: WI + output: + wi_itemized_deduction_credit_potential: 250 # = (3K + 12K - 10K) * 5% diff --git a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py new file mode 100644 index 00000000000..01a8b310a6f --- /dev/null +++ b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py @@ -0,0 +1,295 @@ +"""Properties of the section 1341 claim of right computation. + +26 U.S.C. 1341(a): when a filer repays more than $3,000 of income included in +an earlier year under a claim of right, federal tax is the lesser of (4) the +tax with a deduction for the repayment and (5) the tax without it minus the +decrease in the earlier year's tax from excluding the income. Ties use (4) +(26 CFR 1.1341-1(b)(3)); any excess of the decrease over the tax is refunded +(1341(b)(1)). North Carolina allows no claim of right deduction under +1341(a)(5) (G.S. 105-153.5(a)(2)d) and instead treats the increase in the +earlier year's North Carolina tax as a payment (G.S. 105-266.2). + +YAML cases check single households from Publication 525. These tests check +properties at every point of a grid, one vectorized simulation per year and +scenario, and compare the model's branch computations with independent +simulations that fix the method as an input: + +- the lesser-of rule: income tax equals the smaller of the tax with the + deduction method fixed and the tax with no repayment at all less the + prior-year decrease, ties going to the deduction; +- the branches agree with those independent simulations; +- the deduction is the whole repayment or nothing, the credit is the whole + decrease or nothing, and never both; +- a repayment of $3,000 or less changes nothing (section 67(g)); +- the computation never raises tax, and tax never rises with the repayment + (also across the $3,000 threshold) or with the prior-year decrease; +- North Carolina's deduction is zero exactly when the credit method applies, + and its payment is then the prior-year North Carolina tax increase; +- under the credit method the repayment changes no state's income tax + (section 1341(b)(3): the deduction is not taken into account for any other + purpose), and under the deduction method it changes none in Arizona, Kansas + or Wisconsin, whose laws leave the federal deduction out. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.variables.household.demographic.geographic.state_code import ( + StateCode, +) + +REPAYMENTS = [0, 2_999, 3_000, 3_001, 5_000, 20_000, 150_000] +DECREASES = [0, 400, 2_500, 12_000, 60_000] +WAGES = [0, 30_000, 80_000, 400_000, 1_500_000] +CHARITY = [0, 20_000] +AXES = [REPAYMENTS, DECREASES, WAGES, CHARITY] +GRID = list(itertools.product(*AXES)) +SHAPE = tuple(len(axis) for axis in AXES) +REPAYMENT, DECREASE, WAGE, CHARITABLE = ( + np.array([point[i] for point in GRID], dtype=float) for i in range(len(AXES)) +) +# An illustrative North Carolina prior-year tax increase on the repaid income. +NC_INCREASE = 0.05 * REPAYMENT +THRESHOLD = 3_000 +YEARS = [2025, 2026] +TOLERANCE = 0.01 +# The 50 states and the District of Columbia. +STATES = [ + state.name + for state in StateCode + if state.name not in {"AA", "AE", "AP", "AS", "GU", "MP", "PR", "PW", "VI"} +] +# States whose itemized deductions leave out the federal claim of right +# repayment deduction: A.R.S. 43-1021(9), K.S.A. 79-32,120(a) and Wis. Stat. +# 71.07(5)(a)7. +STATES_WITHOUT_THE_DEDUCTION = ["AZ", "KS", "WI"] +VARIABLES = [ + "income_tax", + "claim_of_right_section_1341_eligible", + "claim_of_right_credit_applies", + "claim_of_right_deduction", + "claim_of_right_credit", + "nc_claim_of_right_deduction", + "nc_claim_of_right_payment", + "nc_income_tax_before_refundable_credits", + "nc_income_tax", +] + + +def grid_simulation(year, repayment, decrease, nc_increase, tax_units=None): + """One single-person North Carolina tax unit per grid point.""" + situation = {"people": {}, "tax_units": {}, "households": {}} + for i in range(len(GRID)): + person = f"p{i}" + situation["people"][person] = { + "age": {year: 40}, + "employment_income": {year: WAGE[i]}, + "charitable_cash_donations": {year: CHARITABLE[i]}, + "claim_of_right_repayment": {year: float(repayment[i])}, + "claim_of_right_prior_year_tax_decrease": {year: float(decrease[i])}, + "nc_claim_of_right_prior_year_tax_increase": {year: float(nc_increase[i])}, + } + situation["tax_units"][f"t{i}"] = { + "members": [person], + **{ + name: {year: bool(values[i])} + for name, values in (tax_units or {}).items() + }, + } + situation["households"][f"h{i}"] = { + "members": [person], + "state_code": {year: "NC"}, + } + return Simulation(situation=situation) + + +def calculate(simulation, year, variables): + return {v: np.asarray(simulation.calculate(v, year)) for v in variables} + + +@pytest.fixture(scope="module") +def results(): + zeros = np.zeros_like(REPAYMENT) + ones = np.ones_like(REPAYMENT) + out = {} + for year in YEARS: + model = grid_simulation(year, REPAYMENT, DECREASE, NC_INCREASE) + out[year] = calculate( + model, + year, + VARIABLES + + [ + "income_tax_if_claiming_claim_of_right_deduction", + "income_tax_if_claiming_claim_of_right_credit", + ], + ) + # Independent references: no repayment at all, and the deduction + # method fixed as an input rather than chosen in a branch. + none = grid_simulation(year, zeros, zeros, zeros) + out[year]["tax_without_repayment"] = np.asarray( + none.calculate("income_tax", year) + ) + deduction = grid_simulation( + year, + REPAYMENT, + DECREASE, + NC_INCREASE, + {"claim_of_right_credit_applies": zeros}, + ) + out[year]["tax_with_deduction"] = np.asarray( + deduction.calculate("income_tax", year) + ) + credit = grid_simulation( + year, + REPAYMENT, + DECREASE, + NC_INCREASE, + {"claim_of_right_credit_applies": ones}, + ) + out[year]["nc_deduction_if_credit"] = np.asarray( + credit.calculate("nc_claim_of_right_deduction", year) + ) + out[year]["nc_deduction_if_deduction"] = np.asarray( + deduction.calculate("nc_claim_of_right_deduction", year) + ) + return out + + +def eligible(): + return REPAYMENT > THRESHOLD + + +@pytest.mark.parametrize("year", YEARS) +def test_tax_is_the_lesser_of_the_two_methods(results, year): + r = results[year] + tax_with_deduction = r["tax_with_deduction"] + tax_with_credit = r["tax_without_repayment"] - DECREASE + credit = eligible() & (tax_with_credit < tax_with_deduction - TOLERANCE) + assert credit.any() and (eligible() & ~credit).any(), "both methods occur" + np.testing.assert_array_equal(r["claim_of_right_credit_applies"], credit) + expected = np.where( + eligible(), + np.where(credit, tax_with_credit, tax_with_deduction), + r["tax_without_repayment"], + ) + np.testing.assert_allclose(r["income_tax"], expected, atol=TOLERANCE) + + +@pytest.mark.parametrize("year", YEARS) +def test_branches_match_independent_simulations(results, year): + r = results[year] + np.testing.assert_allclose( + r["income_tax_if_claiming_claim_of_right_deduction"], + r["tax_with_deduction"], + atol=TOLERANCE, + ) + tax_with_credit = r["tax_without_repayment"] - DECREASE + np.testing.assert_allclose( + r["income_tax_if_claiming_claim_of_right_credit"][eligible()], + tax_with_credit[eligible()], + atol=TOLERANCE, + ) + + +@pytest.mark.parametrize("year", YEARS) +def test_deduction_and_credit_are_all_or_nothing_and_exclusive(results, year): + r = results[year] + credit_applies = r["claim_of_right_credit_applies"] + deduction = r["claim_of_right_deduction"] + credit = r["claim_of_right_credit"] + np.testing.assert_array_equal(r["claim_of_right_section_1341_eligible"], eligible()) + np.testing.assert_allclose( + deduction, np.where(eligible() & ~credit_applies, REPAYMENT, 0) + ) + np.testing.assert_allclose(credit, np.where(credit_applies, DECREASE, 0)) + assert not np.any((deduction > 0) & (credit > 0)) + + +@pytest.mark.parametrize("year", YEARS) +def test_a_repayment_of_3000_or_less_changes_nothing(results, year): + r = results[year] + small = ~eligible() + assert not r["claim_of_right_credit_applies"][small].any() + np.testing.assert_allclose( + r["income_tax"][small], r["tax_without_repayment"][small], atol=TOLERANCE + ) + + +@pytest.mark.parametrize("year", YEARS) +def test_tax_never_rises(results, year): + r = results[year] + assert np.all(r["income_tax"] <= r["tax_without_repayment"] + TOLERANCE) + table = r["income_tax"].reshape(SHAPE) + assert np.all(np.diff(table, axis=0) <= TOLERANCE), "rises with the repayment" + assert np.all(np.diff(table, axis=1) <= TOLERANCE), "rises with the decrease" + + +@pytest.mark.parametrize("year", YEARS) +def test_a_decrease_above_the_tax_is_refunded(results, year): + r = results[year] + credit_applies = r["claim_of_right_credit_applies"] + refunded = credit_applies & (DECREASE > r["tax_without_repayment"]) + assert refunded.any(), "the grid should include refunds" + assert np.all(r["income_tax"][refunded] < 0) + + +@pytest.mark.parametrize("year", YEARS) +def test_north_carolina_follows_the_federal_method(results, year): + r = results[year] + credit_applies = r["claim_of_right_credit_applies"] + np.testing.assert_allclose( + r["nc_claim_of_right_deduction"], + np.where(credit_applies, 0, r["nc_deduction_if_deduction"]), + ) + assert np.all(r["nc_deduction_if_credit"] == 0) + np.testing.assert_allclose( + r["nc_claim_of_right_payment"], np.where(credit_applies, NC_INCREASE, 0) + ) + np.testing.assert_allclose( + r["nc_income_tax"], + r["nc_income_tax_before_refundable_credits"] - r["nc_claim_of_right_payment"], + atol=TOLERANCE, + ) + + +def state_income_tax(year, repayment, credit_applies=None): + """State income tax of an itemizing single filer in each state.""" + situation = {"people": {}, "tax_units": {}, "households": {}} + for i, state in enumerate(STATES): + situation["people"][f"p{i}"] = { + "age": {year: 40}, + "employment_income": {year: 60_000}, + "charitable_cash_donations": {year: 25_000}, + "claim_of_right_repayment": {year: repayment}, + "claim_of_right_prior_year_tax_decrease": {year: 2_500}, + } + situation["tax_units"][f"t{i}"] = {"members": [f"p{i}"]} + if credit_applies is not None: + situation["tax_units"][f"t{i}"]["claim_of_right_credit_applies"] = { + year: credit_applies + } + situation["households"][f"h{i}"] = { + "members": [f"p{i}"], + "state_code": {year: state}, + } + simulation = Simulation(situation=situation) + return np.asarray(simulation.calculate("state_income_tax", year)) + + +def test_states_follow_section_1341_b_3(): + # One year: each of these three simulations computes every state's tax. + year = 2025 + without_repayment = state_income_tax(year, 0) + with_credit = state_income_tax(year, 10_000, credit_applies=True) + np.testing.assert_allclose(with_credit, without_repayment, atol=TOLERANCE) + with_deduction = state_income_tax(year, 10_000, credit_applies=False) + assert np.any(np.abs(with_deduction - without_repayment) > 1), ( + "the deduction should reach the states that follow federal itemized deductions" + ) + excluded = np.isin(STATES, STATES_WITHOUT_THE_DEDUCTION) + np.testing.assert_allclose( + with_deduction[excluded], without_repayment[excluded], atol=TOLERANCE + ) diff --git a/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py b/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py index 30916228f82..f4bd7090559 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py @@ -12,6 +12,8 @@ class az_itemized_deductions(Variable): reference = ( "https://law.justia.com/codes/arizona/2022/title-43/section-43-1042/", "https://www.azleg.gov/ars/43/01042.htm", + "https://www.azleg.gov/ars/43/01021.htm", + "https://www.azleg.gov/ars/43/01029.htm", "https://azdor.gov/forms/individual/itemized-deduction-adjustments-form", "https://azdor.gov/forms/individual/form-140-resident-personal-income-tax-form-calculating", ) @@ -23,6 +25,9 @@ def formula(tax_unit, period, parameters): az_itemized = parameters(period).gov.states.az.tax.income.deductions.itemized # Medical and charitable are handled separately below; the state and # local tax deduction is also handled separately so Arizona can cap it. + # Arizona adds the federal claim of right repayment deduction back to + # Arizona gross income (A.R.S. 43-1021(9)) and computes its tax + # without it (A.R.S. 43-1029), so it gives no net Arizona deduction. deductions = [ deduction for deduction in p.itemized_deductions @@ -31,6 +36,7 @@ def formula(tax_unit, period, parameters): "medical_expense_deduction", "charitable_deduction", "salt_deduction", + "claim_of_right_deduction", ] ] federal_deductions = add(tax_unit, period, deductions) diff --git a/policyengine_us/variables/gov/states/ks/tax/income/deductions/ks_itemized_deductions.py b/policyengine_us/variables/gov/states/ks/tax/income/deductions/ks_itemized_deductions.py index fd0b8dee13f..fa987b8ec7c 100644 --- a/policyengine_us/variables/gov/states/ks/tax/income/deductions/ks_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/ks/tax/income/deductions/ks_itemized_deductions.py @@ -10,6 +10,7 @@ class ks_itemized_deductions(Variable): reference = ( "https://www.ksrevenue.gov/pdf/ip21.pdf", "https://www.ksrevenue.gov/pdf/ip22.pdf", + "https://www.ksrevisor.gov/statutes/chapters/ch79/079_032_0120.html", ) defined_for = StateCode.KS @@ -25,4 +26,9 @@ def formula(tax_unit, period, parameters): # compute itemized deduction maximum itm_deds_less_salt = tax_unit("itemized_deductions_less_salt", period) uncapped_property_taxes = add(tax_unit, period, ["real_estate_taxes"]) - return itm_deds_less_salt + uncapped_property_taxes + # K.S.A. 79-32,120(a) limits Kansas itemized deductions to charitable + # contributions, medical care, qualified residence interest and + # property taxes, so the federal claim of right repayment deduction + # is not one of them. + claim_of_right_deduction = tax_unit("claim_of_right_deduction", period) + return itm_deds_less_salt - claim_of_right_deduction + uncapped_property_taxes diff --git a/policyengine_us/variables/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.py b/policyengine_us/variables/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.py index 43168c0596f..5f16cde2e48 100644 --- a/policyengine_us/variables/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.py +++ b/policyengine_us/variables/gov/states/or/tax/income/subtractions/or_federal_tax_liability_subtraction.py @@ -10,6 +10,8 @@ class or_federal_tax_liability_subtraction(Variable): reference = ( "https://www.oregon.gov/dor/forms/FormsPubs/publication-or-17_101-431_2021.pdf#page=71", "https://www.oregonlegislature.gov/bills_laws/ors/ors316.html", # Subsection 316.800 + # 2025 Publication OR-17, federal tax worksheet, PDF pages 71-72 + "https://www.oregon.gov/dor/forms/FormsPubs/publication-or-17_101-431_2025.pdf#page=71", ) defined_for = StateCode.OR @@ -17,7 +19,12 @@ def formula(tax_unit, period, parameters): # calculate Oregon concept of federal income tax federal_itax = tax_unit("income_tax", period) federal_eitc = tax_unit("eitc", period) - or_federal_income_tax = max_(0, federal_itax + federal_eitc) + # The federal tax worksheet starts from Form 1040 line 22 and does + # not subtract the section 1341 credit (Schedule 3, line 13b). + claim_of_right_credit = tax_unit("claim_of_right_credit", period) + or_federal_income_tax = max_( + 0, federal_itax + federal_eitc + claim_of_right_credit + ) # limit subtraction based on caps scaled to federal AGI filing_status = tax_unit("filing_status", period) status = filing_status.possible_values diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/itemized_deduction/wi_itemized_deduction_credit_potential.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/itemized_deduction/wi_itemized_deduction_credit_potential.py index da76fe63638..5228fd84ff3 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/itemized_deduction/wi_itemized_deduction_credit_potential.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/itemized_deduction/wi_itemized_deduction_credit_potential.py @@ -11,11 +11,20 @@ class wi_itemized_deduction_credit_potential(Variable): "https://www.revenue.wi.gov/TaxForms2021/2021-Form1f.pdf#page=4", "https://www.revenue.wi.gov/TaxForms2022/2022-Form1f.pdf#page=4", "https://docs.legis.wisconsin.gov/misc/lfb/informational_papers/january_2023/0002_individual_income_tax_informational_paper_2.pdf#page=19", + # Wis. Stat. 71.07(5)(a) + "https://docs.legis.wisconsin.gov/statutes/statutes/71.pdf#page=39", + "https://www.revenue.wi.gov/TaxForms2025/2025-Form1.pdf#page=4", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): - wi_itmded = tax_unit("itemized_deductions_less_salt", period) + # Wis. Stat. 71.07(5)(a)7 leaves miscellaneous itemized deductions + # out of the credit whether or not the two percent floor applies to + # them, and Schedule 1 has no line for other itemized deductions, so + # the federal claim of right repayment deduction does not count. + wi_itmded = tax_unit("itemized_deductions_less_salt", period) - tax_unit( + "claim_of_right_deduction", period + ) wi_stdded = tax_unit("wi_standard_deduction", period) excess_itmded = max_(0, wi_itmded - wi_stdded) p = parameters(period).gov.states.wi.tax.income From 09ee710edced956eaf0e39df0f2dcca577dfa48a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 20:29:27 -0400 Subject: [PATCH 05/11] Document the unmodeled pre-2018 small repayment deduction; changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/federal-claim-of-right-1341.added.md | 1 + .../deductions/itemizing/claim_of_right_deduction.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog.d/federal-claim-of-right-1341.added.md diff --git a/changelog.d/federal-claim-of-right-1341.added.md b/changelog.d/federal-claim-of-right-1341.added.md new file mode 100644 index 00000000000..1ece6e26c6d --- /dev/null +++ b/changelog.d/federal-claim-of-right-1341.added.md @@ -0,0 +1 @@ +Add the federal claim of right repayment deduction and section 1341 credit (26 U.S.C. 1341), North Carolina's bar on its claim of right deduction when federal tax is computed under section 1341(a)(5), and North Carolina's tax payment for restored income (G.S. 105-266.2). diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py index b40f5a31ce4..1af60beb16d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py @@ -13,7 +13,10 @@ class claim_of_right_deduction(Variable): "under section 1341(a)(4). It is not a miscellaneous itemized " "deduction, so neither the two percent floor nor section 67(g) " "applies. Under section 1341(a)(5) the repayment is not deducted " - "(section 1341(b)(3))." + "(section 1341(b)(3)). Not modeled: before 2018 a repayment of the " + "threshold amount or less was a miscellaneous itemized deduction " + "subject to the two percent floor; section 67(g) disallows it from " + "2018." ) definition_period = YEAR reference = ( From 03439621bcfd1639f7887537e2691121b6265b2d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 20:37:46 -0400 Subject: [PATCH 06/11] Fix the federal method in the North Carolina worksheet property test to keep it at two seconds Co-Authored-By: Claude Opus 5.5 --- households_branch.err | 0 .../tests/unit/test_nc_claim_of_right_properties.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 households_branch.err diff --git a/households_branch.err b/households_branch.err new file mode 100644 index 00000000000..e69de29bb2d diff --git a/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py index 7ff5a694e8d..d86fc79c5d5 100644 --- a/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py +++ b/policyengine_us/tests/unit/test_nc_claim_of_right_properties.py @@ -65,6 +65,12 @@ def grid_simulation(year, people, tax_units): } situation["tax_units"][f"t{i}"] = { "members": [person], + # These tests check the deduction's arithmetic, which applies + # when federal tax is computed with the deduction (section + # 1341(a)(4)). Fixing that method here skips the two extra + # federal tax computations that choose it; + # test_claim_of_right_section_1341_properties.py covers the choice. + "claim_of_right_credit_applies": {year: False}, **{name: {year: float(values[i])} for name, values in tax_units.items()}, } situation["households"][f"h{i}"] = { From 8f268ca7949c93955f510956b050de5e1a316b95 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 20:45:49 -0400 Subject: [PATCH 07/11] Remove a stray empty log file Co-Authored-By: Claude Opus 5.5 --- households_branch.err | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 households_branch.err diff --git a/households_branch.err b/households_branch.err deleted file mode 100644 index e69de29bb2d..00000000000 From 6c2a76e8b8e2476f9bd0377e895005afe01abc4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 21:38:38 -0400 Subject: [PATCH 08/11] Address review: isolate the method branches, compare to the cent, return-level prior-year inputs, California and Montana flows - The branches that fix the claim of right method start from inputs only, and the itemization branches settle the method in the parent first, so a tax liability calculated before the method is known cannot leak into them. - Ties are compared to the cent, so a one-cent difference picks the credit. - The prior-year federal decrease and NC increase are return-level inputs defined on chapter 1 and Article 4 tax. - California leaves out the federal deduction (Schedule CA Part II line 16); Montana deducts it through 2023 (Form 2 itemized line 18). - Section 67(g) is now 67(h); document the Arizona and NC coverage limits. Co-Authored-By: Claude Opus 5.5 --- .../deductions/itemized/misc_deductions.yaml | 4 + .../claim_of_right_credit_applies.yaml | 57 +++++- .../credits/claim_of_right/integration.yaml | 8 +- .../itemizing/claim_of_right_deduction.yaml | 3 + ...ca_itemized_deductions_pre_limitation.yaml | 12 ++ .../itemized/mt_claim_of_right_deduction.yaml | 82 ++++++++ .../nc_claim_of_right_payment.yaml | 30 +-- .../itemized/nc_claim_of_right_deduction.yaml | 29 ++- ..._claim_of_right_section_1341_properties.py | 193 +++++++++++------- policyengine_us/tools/period_branch.py | 13 +- .../claim_of_right/claim_of_right_credit.py | 7 +- .../claim_of_right_credit_applies.py | 11 +- .../claim_of_right_prior_year_tax_decrease.py | 17 +- ...e_tax_if_claiming_claim_of_right_credit.py | 3 + ...ax_if_claiming_claim_of_right_deduction.py | 3 + .../itemizing/claim_of_right_deduction.py | 13 +- .../deductions/tax_liability_if_itemizing.py | 3 + .../tax_liability_if_not_itemizing.py | 3 + .../itemized/az_itemized_deductions.py | 3 + .../ca_itemized_deductions_pre_limitation.py | 8 +- .../general/mt_claim_of_right_deduction.py | 24 +++ .../nc_claim_of_right_payment.py | 13 +- ..._claim_of_right_prior_year_tax_increase.py | 9 +- 23 files changed, 398 insertions(+), 150 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_claim_of_right_deduction.yaml create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_claim_of_right_deduction.py diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml index 1eeb0351b1a..4337e223473 100644 --- a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/misc_deductions.yaml @@ -3,6 +3,8 @@ values: 2021-01-01: - mt_child_dependent_care_expense_deduction - casualty_loss_deduction + # Form 2 itemized deductions line 18 follows federal Schedule A line 16. + - mt_claim_of_right_deduction 2024-01-01: [] metadata: @@ -10,6 +12,8 @@ metadata: label: Montana miscellaneous deductions period: year reference: + - title: 2023 Montana Form 2 instructions, itemized deductions line 18 + href: https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=39 - title: Montana Individual Income Tax Return (Form 2) 2024 href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=8 - title: Montana Individual Income Tax Return (Form 2) 2022 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml index 2616941594a..d380d895243 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.yaml @@ -20,7 +20,7 @@ claim_of_right_credit_applies: false income_tax: 5_155.5 -- name: Case 2, a prior-year decrease 50 cents larger makes the credit cheaper. +- name: Case 2, a prior-year decrease 50 cents larger makes the credit apply. period: 2025 input: age: 40 @@ -52,7 +52,7 @@ claim_of_right_credit_applies: false claim_of_right_deduction: 0 claim_of_right_credit: 0 - # Section 67(g) disallows the miscellaneous deduction, so tax is + # Section 67(g) (now 67(h)) disallows the miscellaneous deduction, so tax is # unchanged at the tax on $49,950. income_tax: 5_903 @@ -73,3 +73,56 @@ # $65,700 - $15,750 - $3,001 = $46,949; $1,192.50 + 12% x $35,024. taxable_income: 46_949 income_tax: 5_395.38 + +- name: Case 5, the credit applies when it gives one cent less tax. + period: 2025 + input: + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 5_000 + # $5,903 - $747.51 = $5,155.49, one cent below the tax with the deduction. + claim_of_right_prior_year_tax_decrease: 747.51 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: true + claim_of_right_credit: 747.51 + income_tax: 5_155.49 + +- name: Case 6, the method is the same when itemizing is decided by comparing deductions. + # Publication 525 (2025) Example 40 with + # gov.simulation.branch_to_determine_itemization off. + period: 2025 + input: + gov.simulation.branch_to_determine_itemization: false + age: 40 + employment_income: 65_700 + charitable_cash_donations: 15_750 + claim_of_right_repayment: 5_000 + claim_of_right_prior_year_tax_decrease: 568 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + income_tax_if_claiming_claim_of_right_deduction: 5_155.5 + income_tax_if_claiming_claim_of_right_credit: 5_335 + claim_of_right_credit_applies: false + income_tax: 5_155.5 + +- name: Case 7, the credit applies for a standard deduction filer when itemizing is decided by comparing deductions. + period: 2025 + input: + gov.simulation.branch_to_determine_itemization: false + age: 40 + employment_income: 40_000 + claim_of_right_repayment: 10_000 + claim_of_right_prior_year_tax_decrease: 2_200 + state_sales_tax: 0 + local_sales_tax: 0 + state_code: TX + output: + claim_of_right_credit_applies: true + claim_of_right_credit: 2_200 + income_tax: 471.5 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml index b5b2c47bba6..4d457ec6996 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/claim_of_right/integration.yaml @@ -121,7 +121,7 @@ income_tax_before_refundable_credits: 2_671.5 income_tax: -328.5 -- name: Case 6, a joint return counts the spouses' repayments and decreases, not a dependent's. +- name: Case 6, a joint return counts the spouses' repayments, not a dependent's. period: 2025 input: people: @@ -129,19 +129,19 @@ age: 45 employment_income: 90_000 claim_of_right_repayment: 2_000 - claim_of_right_prior_year_tax_decrease: 300 person2: age: 44 claim_of_right_repayment: 2_000 - claim_of_right_prior_year_tax_decrease: 200 person3: age: 30 is_tax_unit_dependent: true claim_of_right_repayment: 9_000 - claim_of_right_prior_year_tax_decrease: 1_000 tax_units: tax_unit: members: [person1, person2, person3] + # The decrease in the couple's earlier joint tax from excluding the + # $4,000 they repaid. + claim_of_right_prior_year_tax_decrease: 500 state_sales_tax: 0 local_sales_tax: 0 households: diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml index feac0b40e12..8afcaf3dcbd 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.yaml @@ -35,6 +35,9 @@ age: 40 employment_income: 400_000 claim_of_right_repayment: 50_000 + # Federal income tax is modeled from 2018, so fix the method here + # instead of deriving it from 2017 tax. + claim_of_right_credit_applies: false state_sales_tax: 0 local_sales_tax: 0 state_code: TX diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml index eafbae0aeac..db58ea0aa7a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.yaml @@ -15,3 +15,15 @@ real_estate_taxes: 5_000 output: ca_itemized_deductions_pre_limitation: 13_000 + +- name: Case 3, the federal claim of right repayment deduction does not carry over. + # 2025 Schedule CA (540) instructions, Part II line 16: California allows + # its own deduction or credit, and neither for income it did not tax. + period: 2025 + input: + state_code: CA + interest_deduction: 6_000 + claim_of_right_deduction: 5_000 + output: + itemized_deductions_less_salt: 11_000 + ca_itemized_deductions_pre_limitation: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_claim_of_right_deduction.yaml new file mode 100644 index 00000000000..b1051767977 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_claim_of_right_deduction.yaml @@ -0,0 +1,82 @@ +# 2023 Form 2 instructions, itemized deductions line 18: "These deductions +# are the same deductions that are allowed on your federal Form 1040, +# Schedule A, line 16." +- name: Case 1, Montana follows the federal deduction through 2023. + period: 2023 + input: + people: + person1: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [person1] + claim_of_right_deduction: 5_000 + households: + household: + members: [person1] + state_code: MT + output: + mt_claim_of_right_deduction: 5_000 + mt_misc_deductions: 5_000 + +- name: Case 2, no Montana deduction when the federal deduction is zero. + period: 2023 + input: + people: + person1: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [person1] + claim_of_right_deduction: 0 + households: + household: + members: [person1] + state_code: MT + output: + mt_claim_of_right_deduction: 0 + +- name: Case 3, each spouse deducts their own repayment and a dependent's is left out. + period: 2023 + input: + people: + person1: + age: 45 + claim_of_right_repayment: 3_000 + person2: + age: 44 + claim_of_right_repayment: 1_500 + person3: + age: 20 + is_tax_unit_dependent: true + claim_of_right_repayment: 9_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + claim_of_right_deduction: 4_500 + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_claim_of_right_deduction: [3_000, 1_500, 0] + +- name: Case 4, from 2024 the miscellaneous deductions list is empty. + period: 2024 + input: + people: + person1: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [person1] + claim_of_right_deduction: 5_000 + households: + household: + members: [person1] + state_code: MT + output: + mt_misc_deductions: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml index d2f4445cdc2..a2181be2bc1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.yaml @@ -19,32 +19,10 @@ output: nc_claim_of_right_payment: 0 -- name: Case 3, a joint return sums the spouses' increases, not a dependent's. - period: 2025 - input: - people: - person1: - age: 45 - nc_claim_of_right_prior_year_tax_increase: 200 - person2: - age: 44 - nc_claim_of_right_prior_year_tax_increase: 100 - person3: - age: 30 - is_tax_unit_dependent: true - nc_claim_of_right_prior_year_tax_increase: 1_000 - tax_units: - tax_unit: - members: [person1, person2, person3] - claim_of_right_credit_applies: true - households: - household: - members: [person1, person2, person3] - state_code: NC - output: - nc_claim_of_right_payment: 300 - -- name: Case 4, no payment outside North Carolina. +- name: Case 3, the payment is modeled for current North Carolina residents only. + # A coverage limit of the model, not a rule of G.S. 105-266.2: a filer who + # paid the earlier North Carolina tax and has since moved away is not + # covered. period: 2025 input: claim_of_right_credit_applies: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml index e389edb9a97..3341d040242 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/itemized/nc_claim_of_right_deduction.yaml @@ -419,13 +419,13 @@ age: 40 employment_income: 80_000 claim_of_right_repayment: 5_000 - claim_of_right_prior_year_tax_decrease: 1_100 - nc_claim_of_right_prior_year_tax_increase: 212.5 real_estate_taxes: 4_000 tax_units: tax_unit: members: [head] charitable_deduction: 8_000 + claim_of_right_prior_year_tax_decrease: 1_100 + nc_claim_of_right_prior_year_tax_increase: 212.5 households: household: members: [head] @@ -451,13 +451,13 @@ age: 40 employment_income: 80_000 claim_of_right_repayment: 5_000 - claim_of_right_prior_year_tax_decrease: 568 - nc_claim_of_right_prior_year_tax_increase: 212.5 real_estate_taxes: 4_000 tax_units: tax_unit: members: [head] charitable_deduction: 8_000 + claim_of_right_prior_year_tax_decrease: 568 + nc_claim_of_right_prior_year_tax_increase: 212.5 households: household: members: [head] @@ -467,3 +467,24 @@ nc_claim_of_right_deduction: 5_000 nc_claim_of_right_payment: 0 nc_income_tax: 2_677.50 + +- name: Before 2018 a repayment above $3,000 with no prior-year decrease keeps the deduction. + # No method is supplied: with no prior-year tax decrease the credit method + # cannot give less tax, so tax is computed with the deduction. + period: 2016 + input: + people: + head: + age: 40 + claim_of_right_repayment: 5_000 + tax_units: + tax_unit: + members: [head] + adjusted_gross_income: 50_000 + households: + household: + members: [head] + state_code: NC + output: + claim_of_right_credit_applies: false + nc_claim_of_right_deduction: 5_000 diff --git a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py index 01a8b310a6f..d5095f249e4 100644 --- a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py +++ b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py @@ -1,4 +1,4 @@ -"""Properties of the section 1341 claim of right computation. +"""Branch isolation and cross-case properties of the section 1341 computation. 26 U.S.C. 1341(a): when a filer repays more than $3,000 of income included in an earlier year under a claim of right, federal tax is the lesser of (4) the @@ -9,26 +9,35 @@ 1341(a)(5) (G.S. 105-153.5(a)(2)d) and instead treats the increase in the earlier year's North Carolina tax as a payment (G.S. 105-266.2). -YAML cases check single households from Publication 525. These tests check -properties at every point of a grid, one vectorized simulation per year and -scenario, and compare the model's branch computations with independent -simulations that fix the method as an input: - -- the lesser-of rule: income tax equals the smaller of the tax with the - deduction method fixed and the tax with no repayment at all less the - prior-year decrease, ties going to the deduction; -- the branches agree with those independent simulations; -- the deduction is the whole repayment or nothing, the credit is the whole - decrease or nothing, and never both; -- a repayment of $3,000 or less changes nothing (section 67(g)); -- the computation never raises tax, and tax never rises with the repayment - (also across the $3,000 threshold) or with the prior-year decrease; -- North Carolina's deduction is zero exactly when the credit method applies, - and its payment is then the prior-year North Carolina tax increase; -- under the credit method the repayment changes no state's income tax - (section 1341(b)(3): the deduction is not taken into account for any other - purpose), and under the deduction method it changes none in Arizona, Kansas - or Wisconsin, whose laws leave the federal deduction out. +The policy cases are in YAML (``gov/irs/credits/claim_of_right``). These tests +cover what a YAML case cannot. The model picks the method by calculating +federal tax in two override branches, so the tests build separate simulations +and compare them: + +- each branch equals a simulation that fixes the method as an input before + anything is calculated, and income tax equals the smaller of the two + (ties to the deduction), where the credit side comes from a simulation with + no repayment at all less the prior-year decrease; +- results do not depend on which variable is calculated first, including + when a tax liability under one itemizing choice is calculated before the + method is known; +- a later year calculated on the same simulation equals a simulation of that + year alone; +- relations between cases over a grid: tax never rises with the repayment + (also across the $3,000 threshold) or with the prior-year decrease, and a + repayment of $3,000 or less changes nothing (section 67(h), formerly 67(g)). + +They also check, at every grid point, that the deduction is the whole +repayment or nothing, the credit is the whole decrease or nothing, and never +both; that North Carolina's deduction is zero whenever the credit method +applies and otherwise is what it is with the deduction method fixed; and that +its payment is the prior-year increase when the credit method applies and zero +otherwise. + +Last, one itemizing household per state: with the credit method fixed, the +repayment leaves every state's income tax unchanged in the model (no state +input is supplied), and with the deduction method fixed it leaves Arizona's, +California's, Kansas's and Wisconsin's unchanged. """ import itertools @@ -53,6 +62,7 @@ ) # An illustrative North Carolina prior-year tax increase on the repaid income. NC_INCREASE = 0.05 * REPAYMENT +ZEROS = np.zeros_like(REPAYMENT) THRESHOLD = 3_000 YEARS = [2025, 2026] TOLERANCE = 0.01 @@ -62,12 +72,19 @@ for state in StateCode if state.name not in {"AA", "AE", "AP", "AS", "GU", "MP", "PR", "PW", "VI"} ] -# States whose itemized deductions leave out the federal claim of right -# repayment deduction: A.R.S. 43-1021(9), K.S.A. 79-32,120(a) and Wis. Stat. -# 71.07(5)(a)7. -STATES_WITHOUT_THE_DEDUCTION = ["AZ", "KS", "WI"] +# States whose own rules leave out the federal claim of right repayment +# deduction: A.R.S. 43-1021(9), the California Schedule CA (540) instructions +# for Part II line 16, K.S.A. 79-32,120(a) and Wisconsin Form 1 Schedule 1. +STATES_WITHOUT_THE_DEDUCTION = ["AZ", "CA", "KS", "WI"] +BRANCHES = [ + "income_tax_if_claiming_claim_of_right_deduction", + "income_tax_if_claiming_claim_of_right_credit", +] VARIABLES = [ "income_tax", + "tax_unit_itemizes", + "tax_liability_if_itemizing", + "tax_liability_if_not_itemizing", "claim_of_right_section_1341_eligible", "claim_of_right_credit_applies", "claim_of_right_deduction", @@ -76,32 +93,37 @@ "nc_claim_of_right_payment", "nc_income_tax_before_refundable_credits", "nc_income_tax", -] +] + BRANCHES -def grid_simulation(year, repayment, decrease, nc_increase, tax_units=None): +def grid_simulation( + years, repayment=REPAYMENT, decrease=DECREASE, nc_increase=NC_INCREASE, method=None +): """One single-person North Carolina tax unit per grid point.""" + + def yearly(value): + return {year: value for year in years} + situation = {"people": {}, "tax_units": {}, "households": {}} for i in range(len(GRID)): person = f"p{i}" situation["people"][person] = { - "age": {year: 40}, - "employment_income": {year: WAGE[i]}, - "charitable_cash_donations": {year: CHARITABLE[i]}, - "claim_of_right_repayment": {year: float(repayment[i])}, - "claim_of_right_prior_year_tax_decrease": {year: float(decrease[i])}, - "nc_claim_of_right_prior_year_tax_increase": {year: float(nc_increase[i])}, + "age": yearly(40), + "employment_income": yearly(WAGE[i]), + "charitable_cash_donations": yearly(CHARITABLE[i]), + "claim_of_right_repayment": yearly(float(repayment[i])), } - situation["tax_units"][f"t{i}"] = { + tax_unit = { "members": [person], - **{ - name: {year: bool(values[i])} - for name, values in (tax_units or {}).items() - }, + "claim_of_right_prior_year_tax_decrease": yearly(float(decrease[i])), + "nc_claim_of_right_prior_year_tax_increase": yearly(float(nc_increase[i])), } + if method is not None: + tax_unit["claim_of_right_credit_applies"] = yearly(method) + situation["tax_units"][f"t{i}"] = tax_unit situation["households"][f"h{i}"] = { "members": [person], - "state_code": {year: "NC"}, + "state_code": yearly("NC"), } return Simulation(situation=situation) @@ -112,49 +134,26 @@ def calculate(simulation, year, variables): @pytest.fixture(scope="module") def results(): - zeros = np.zeros_like(REPAYMENT) - ones = np.ones_like(REPAYMENT) out = {} for year in YEARS: - model = grid_simulation(year, REPAYMENT, DECREASE, NC_INCREASE) - out[year] = calculate( - model, - year, - VARIABLES - + [ - "income_tax_if_claiming_claim_of_right_deduction", - "income_tax_if_claiming_claim_of_right_credit", - ], - ) - # Independent references: no repayment at all, and the deduction - # method fixed as an input rather than chosen in a branch. - none = grid_simulation(year, zeros, zeros, zeros) + out[year] = calculate(grid_simulation([year]), year, VARIABLES) + # Independent references: no repayment at all, and each method fixed + # as an input rather than chosen in a branch. + none = grid_simulation([year], ZEROS, ZEROS, ZEROS) out[year]["tax_without_repayment"] = np.asarray( none.calculate("income_tax", year) ) - deduction = grid_simulation( - year, - REPAYMENT, - DECREASE, - NC_INCREASE, - {"claim_of_right_credit_applies": zeros}, - ) + deduction = grid_simulation([year], method=False) out[year]["tax_with_deduction"] = np.asarray( deduction.calculate("income_tax", year) ) - credit = grid_simulation( - year, - REPAYMENT, - DECREASE, - NC_INCREASE, - {"claim_of_right_credit_applies": ones}, + out[year]["nc_deduction_if_deduction"] = np.asarray( + deduction.calculate("nc_claim_of_right_deduction", year) ) + credit = grid_simulation([year], method=True) out[year]["nc_deduction_if_credit"] = np.asarray( credit.calculate("nc_claim_of_right_deduction", year) ) - out[year]["nc_deduction_if_deduction"] = np.asarray( - deduction.calculate("nc_claim_of_right_deduction", year) - ) return out @@ -167,7 +166,10 @@ def test_tax_is_the_lesser_of_the_two_methods(results, year): r = results[year] tax_with_deduction = r["tax_with_deduction"] tax_with_credit = r["tax_without_repayment"] - DECREASE - credit = eligible() & (tax_with_credit < tax_with_deduction - TOLERANCE) + # Compared to the cent; equal amounts keep the deduction. + credit = eligible() & ( + np.round(tax_with_credit, 2) < np.round(tax_with_deduction, 2) + ) assert credit.any() and (eligible() & ~credit).any(), "both methods occur" np.testing.assert_array_equal(r["claim_of_right_credit_applies"], credit) expected = np.where( @@ -194,6 +196,37 @@ def test_branches_match_independent_simulations(results, year): ) +@pytest.mark.parametrize( + "first", ["tax_liability_if_itemizing", "tax_liability_if_not_itemizing"] + BRANCHES +) +def test_results_do_not_depend_on_calculation_order(results, first): + # A tax liability under one itemizing choice is calculated in a branch. + # Asked for before the method is known, it must not leave a value behind + # that a later method branch reuses. + year = YEARS[0] + simulation = grid_simulation([year]) + simulation.calculate(first, year) + for variable in VARIABLES: + np.testing.assert_allclose( + np.asarray(simulation.calculate(variable, year)), + results[year][variable], + atol=TOLERANCE, + err_msg=f"{variable} changes when {first} is calculated first", + ) + + +def test_a_later_year_matches_a_simulation_of_that_year_alone(results): + simulation = grid_simulation(YEARS) + simulation.calculate("income_tax", YEARS[0]) + for variable in VARIABLES: + np.testing.assert_allclose( + np.asarray(simulation.calculate(variable, YEARS[1])), + results[YEARS[1]][variable], + atol=TOLERANCE, + err_msg=f"{variable} differs after an earlier year was calculated", + ) + + @pytest.mark.parametrize("year", YEARS) def test_deduction_and_credit_are_all_or_nothing_and_exclusive(results, year): r = results[year] @@ -255,7 +288,7 @@ def test_north_carolina_follows_the_federal_method(results, year): ) -def state_income_tax(year, repayment, credit_applies=None): +def state_income_tax(year, repayment, method=None): """State income tax of an itemizing single filer in each state.""" situation = {"people": {}, "tax_units": {}, "households": {}} for i, state in enumerate(STATES): @@ -264,12 +297,14 @@ def state_income_tax(year, repayment, credit_applies=None): "employment_income": {year: 60_000}, "charitable_cash_donations": {year: 25_000}, "claim_of_right_repayment": {year: repayment}, + } + situation["tax_units"][f"t{i}"] = { + "members": [f"p{i}"], "claim_of_right_prior_year_tax_decrease": {year: 2_500}, } - situation["tax_units"][f"t{i}"] = {"members": [f"p{i}"]} - if credit_applies is not None: + if method is not None: situation["tax_units"][f"t{i}"]["claim_of_right_credit_applies"] = { - year: credit_applies + year: method } situation["households"][f"h{i}"] = { "members": [f"p{i}"], @@ -279,13 +314,13 @@ def state_income_tax(year, repayment, credit_applies=None): return np.asarray(simulation.calculate("state_income_tax", year)) -def test_states_follow_section_1341_b_3(): +def test_states_under_each_federal_method(): # One year: each of these three simulations computes every state's tax. - year = 2025 + year = YEARS[0] without_repayment = state_income_tax(year, 0) - with_credit = state_income_tax(year, 10_000, credit_applies=True) + with_credit = state_income_tax(year, 10_000, method=True) np.testing.assert_allclose(with_credit, without_repayment, atol=TOLERANCE) - with_deduction = state_income_tax(year, 10_000, credit_applies=False) + with_deduction = state_income_tax(year, 10_000, method=False) assert np.any(np.abs(with_deduction - without_repayment) > 1), ( "the deduction should reach the states that follow federal itemized deductions" ) diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 5e47797cf20..7cf5f4c8d2d 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -25,6 +25,12 @@ inputs, each for the periods it was set for, and calculates the rest itself. - A branch reused within its period with different inputs is created again. +- A caller can ask the branch never to keep the parent's calculated values + (``inherit_calculated=False``). The parent can hold a value that depends on + the overridden variable although it has no value for that variable yet: + one it calculated in another branch, which chose the overridden variable + for itself. The branches that fix the claim of right method (26 U.S.C. + 1341) start from inputs for that reason. ``get_branch_for_period`` is the same with no inputs, for branches that change the tax-benefit system rather than inputs: the caller swaps the system @@ -110,11 +116,14 @@ def get_override_branch( name: str, period: Period, inputs: Dict[str, Override], + inherit_calculated: bool = True, ) -> Simulation: """Return ``simulation``'s branch ``name`` for ``period``, with ``inputs`` set. ``inputs`` maps each overridden variable to its value for ``period``, or - to a ``(period, value)`` pair for another period (e.g. a month). + to a ``(period, value)`` pair for another period (e.g. a month). With + ``inherit_calculated=False`` a new branch keeps only inputs, whether or + not the parent has calculated an overridden variable. """ period = to_period(period) overrides = list(_overrides(period, inputs)) @@ -138,7 +147,7 @@ def get_override_branch( ) branch = simulation.get_branch(name) branch.branch_period = period - if parent_knows_override: + if parent_knows_override or not inherit_calculated: drop_inherited_values(branch) for variable, input_period, value in overrides: branch.set_input(variable, input_period, value) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py index dc819e23b2a..494a6cb24e7 100644 --- a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit.py @@ -25,8 +25,5 @@ class claim_of_right_credit(Variable): def formula(tax_unit, period, parameters): eligible = tax_unit("claim_of_right_section_1341_eligible", period) credit_applies = tax_unit("claim_of_right_credit_applies", period) - person = tax_unit.members - decrease = person("claim_of_right_prior_year_tax_decrease", period) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - prior_year_tax_decrease = tax_unit.sum(decrease * head_or_spouse) - return where(eligible & credit_applies, prior_year_tax_decrease, 0) + decrease = tax_unit("claim_of_right_prior_year_tax_decrease", period) + return where(eligible & credit_applies, decrease, 0) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py index 4af8870157f..7c46719ad72 100644 --- a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_credit_applies.py @@ -12,7 +12,9 @@ class claim_of_right_credit_applies(Variable): "repaid income (the credit method). Section 1341 makes the tax the " "lesser of this and the tax computed with the deduction under " "1341(a)(4), so the method is computed, not elected. When the two " - "are equal, the tax is computed under 1341(a)(4)." + "are equal, the tax is computed under 1341(a)(4). The comparison is " + "of federal income tax after credits; the parts of it outside " + "chapter 1 do not depend on the repayment." ) definition_period = YEAR reference = ( @@ -33,7 +35,6 @@ def formula(tax_unit, period, parameters): tax_if_deduction = tax_unit( "income_tax_if_claiming_claim_of_right_deduction", period ) - # Ties go to the deduction (26 CFR 1.1341-1(b)(3)); the tolerance - # keeps floating-point noise from deciding a tie. - TOLERANCE = 0.01 - return eligible & (tax_if_credit < tax_if_deduction - TOLERANCE) + # Ties go to the deduction (26 CFR 1.1341-1(b)(3)). Comparing to the + # cent keeps floating-point noise from deciding a tie. + return eligible & (np.round(tax_if_credit, 2) < np.round(tax_if_deduction, 2)) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py index 5c56be6e87f..6ed12f6a3d1 100644 --- a/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/claim_of_right_prior_year_tax_decrease.py @@ -3,20 +3,23 @@ class claim_of_right_prior_year_tax_decrease(Variable): value_type = float - entity = Person + entity = TaxUnit label = "Prior-year tax decrease from excluding repaid claim of right income" unit = USD documentation = ( - "Decrease in federal income tax for the earlier year or years that " - "would result solely from excluding from gross income the amount " - "repaid this year (claim_of_right_repayment), net of any increase " - "in minimum tax: the tax shown on the earlier return less the tax " - "refigured without the repaid income. The tax unit uses the sum for " - "its head and spouse." + "Decrease in the filers' federal income tax under chapter 1 of the " + "Internal Revenue Code for the earlier year or years that would " + "result solely from excluding from gross income all the income they " + "repaid this year (claim_of_right_repayment): the chapter 1 tax " + "previously determined, after credits and including the alternative " + "minimum tax, less that tax refigured without the repaid income. It " + "leaves out the net investment income tax, self-employment tax and " + "payroll taxes." ) definition_period = YEAR reference = ( "https://www.govinfo.gov/content/pkg/USCODE-2024-title26/html/USCODE-2024-title26-subtitleA-chap1-subchapQ-partV-sec1341.htm", + # 26 CFR 1.1341-1(b)(1)(ii) and (d)(4) "https://www.ecfr.gov/current/title-26/chapter-I/subchapter-A/part-1/subject-group-ECFR4c74bc8cf7c11fb/section-1.1341-1", # Publication 525 (2025), Repayments, method 2, steps 2 and 3 "https://www.irs.gov/pub/irs-prior/p525--2025.pdf#page=36", diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py index b7ece65bc04..91095a583d8 100644 --- a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_credit.py @@ -20,5 +20,8 @@ def formula(tax_unit, period, parameters): "claim_of_right_credit", period, {"claim_of_right_credit_applies": np.ones(tax_unit.count, dtype=bool)}, + # A value the parent calculated in another branch reflects the + # method that branch chose, so start from inputs only. + inherit_calculated=False, ) return branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py index 802f7e1b307..1e5372ca046 100644 --- a/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py +++ b/policyengine_us/variables/gov/irs/credits/claim_of_right/income_tax_if_claiming_claim_of_right_deduction.py @@ -19,5 +19,8 @@ def formula(tax_unit, period, parameters): "claim_of_right_deduction", period, {"claim_of_right_credit_applies": np.zeros(tax_unit.count, dtype=bool)}, + # A value the parent calculated in another branch reflects the + # method that branch chose, so start from inputs only. + inherit_calculated=False, ) return branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py index 1af60beb16d..bb8b52793c1 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/claim_of_right_deduction.py @@ -11,12 +11,13 @@ class claim_of_right_deduction(Variable): "of income included in an earlier year under a claim of right " "(Schedule A, line 16), when tax is computed with the deduction " "under section 1341(a)(4). It is not a miscellaneous itemized " - "deduction, so neither the two percent floor nor section 67(g) " - "applies. Under section 1341(a)(5) the repayment is not deducted " - "(section 1341(b)(3)). Not modeled: before 2018 a repayment of the " - "threshold amount or less was a miscellaneous itemized deduction " - "subject to the two percent floor; section 67(g) disallows it from " - "2018." + "deduction (section 67(b)(9)), so neither the two percent floor nor " + "the suspension of those deductions from 2018 applies. Under " + "section 1341(a)(5) the repayment is not deducted (section " + "1341(b)(3)). Not modeled: before 2018 a repayment of the threshold " + "amount or less was a miscellaneous itemized deduction subject to " + "the two percent floor; from 2018 it is disallowed (section 67(g), " + "redesignated 67(h) by P.L. 119-21)." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py index bbfb5c097ba..b4aa7f73265 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py @@ -10,6 +10,9 @@ class tax_liability_if_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation + # Settle the claim of right method (26 U.S.C. 1341) in this simulation + # first, so that the branch uses it and does not choose its own. + tax_unit("claim_of_right_credit_applies", period) itemized_branch = get_override_branch( simulation, "itemizing", diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py index 07279c5639b..0540ebc3652 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py @@ -11,6 +11,9 @@ class tax_liability_if_not_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation + # Settle the claim of right method (26 U.S.C. 1341) in this simulation + # first, so that the branch uses it and does not choose its own. + tax_unit("claim_of_right_credit_applies", period) non_itemized_branch = get_override_branch( simulation, "not_itemizing", diff --git a/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py b/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py index f4bd7090559..f5d3a3e073f 100644 --- a/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.py @@ -28,6 +28,9 @@ def formula(tax_unit, period, parameters): # Arizona adds the federal claim of right repayment deduction back to # Arizona gross income (A.R.S. 43-1021(9)) and computes its tax # without it (A.R.S. 43-1029), so it gives no net Arizona deduction. + # Leaving it out here gives the same taxable income for an Arizona + # itemizer, but Arizona AGI is not raised by the addition, and the + # Arizona claim of right credit is not modeled. deductions = [ deduction for deduction in p.itemized_deductions diff --git a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py index ed87c390fac..720b278c06e 100644 --- a/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py +++ b/policyengine_us/variables/gov/states/ca/tax/income/deductions/itemized/ca_itemized_deductions_pre_limitation.py @@ -10,6 +10,8 @@ class ca_itemized_deductions_pre_limitation(Variable): reference = ( "https://www.ftb.ca.gov/forms/2021/2021-540-ca-instructions.html", "https://www.ftb.ca.gov/forms/2022/2022-540-ca-instructions.html", + # Schedule CA (540) Part II line 16, claim of right + "https://www.ftb.ca.gov/forms/2025/2025-540-ca-instructions.html", ) defined_for = StateCode.CA @@ -18,4 +20,8 @@ class ca_itemized_deductions_pre_limitation(Variable): "ca_investment_interest_expense_deduction", "real_estate_taxes", ] - subtracts = ["investment_interest_expense"] + # California chooses between its own claim of right deduction and credit + # whichever method federal tax used, and allows neither for income it did + # not tax, so the federal deduction does not carry over. California's own + # rule is not modeled. + subtracts = ["investment_interest_expense", "claim_of_right_deduction"] diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_claim_of_right_deduction.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_claim_of_right_deduction.py new file mode 100644 index 00000000000..201192fc27d --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_claim_of_right_deduction.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class mt_claim_of_right_deduction(Variable): + value_type = float + entity = Person + label = "Montana claim of right repayment deduction" + unit = USD + documentation = ( + "The person's part of the federal claim of right repayment deduction " + "(Schedule A, line 16), which Montana allowed as an itemized " + "deduction through 2023 (Form 2 itemized deductions, line 18)." + ) + definition_period = YEAR + reference = "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=39" + defined_for = StateCode.MT + + def formula(person, period, parameters): + # The federal deduction is the head's and spouse's repayments when + # federal tax is computed with the deduction, and zero otherwise. + deducted = person.tax_unit("claim_of_right_deduction", period) > 0 + repayment = person("claim_of_right_repayment", period) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + return deducted * head_or_spouse * repayment diff --git a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py index 46977319d71..abcfca2ca20 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_payment.py @@ -11,7 +11,11 @@ class nc_claim_of_right_payment(Variable): "considered to have paid North Carolina tax equal to the increase in " "the earlier year's North Carolina tax from including the restored " "income, minus the decrease in this year's North Carolina tax because " - "the item was deductible. An overpayment it creates is refunded." + "the item was deductible. An overpayment it creates is refunded. " + "Modeled for current North Carolina residents only: a filer who " + "paid the earlier North Carolina tax and has since moved away is " + "not covered, because the model computes a state's tax for its " + "residents." ) definition_period = YEAR reference = ( @@ -22,10 +26,9 @@ class nc_claim_of_right_payment(Variable): def formula(tax_unit, period, parameters): credit_applies = tax_unit("claim_of_right_credit_applies", period) - person = tax_unit.members - increase = person("nc_claim_of_right_prior_year_tax_increase", period) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - prior_year_tax_increase = tax_unit.sum(increase * head_or_spouse) + prior_year_tax_increase = tax_unit( + "nc_claim_of_right_prior_year_tax_increase", period + ) # Term (ii), the decrease in this year's North Carolina tax because # the item was deductible, is zero: under section 1341(a)(5) the # repayment is not deducted federally (section 1341(b)(3)), and diff --git a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py index 075c3021eed..814b4df7615 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/credits/claim_of_right/nc_claim_of_right_prior_year_tax_increase.py @@ -3,14 +3,15 @@ class nc_claim_of_right_prior_year_tax_increase(Variable): value_type = float - entity = Person + entity = TaxUnit label = "North Carolina prior-year tax increase from restored claim of right income" unit = USD documentation = ( - "Amount by which North Carolina individual income tax for the earlier " - "year or years increased because the income repaid this year " + "Amount by which the filers' North Carolina individual income tax " + "(Article 4 of Chapter 105) for the earlier year or years was " + "increased because the income they repaid this year " "(claim_of_right_repayment) was included in gross income for that " - "year. The tax unit uses the sum for its head and spouse." + "year." ) definition_period = YEAR reference = "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-266.2.html" From c2b9602416ca02a24da065b1300a7c1940fa083e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 22:09:47 -0400 Subject: [PATCH 09/11] Keep the simulation's claim of right method in the itemizing branches Review r2 found that with itemization branching off, tax_liability_if_not_itemizing used the parent's method when calculated first but chose its own after income_tax. The itemizing branches now take the method as an input. A branch keeps its parent's cache when an override equals the value the parent already holds, so ordinary simulations still share it. Adds the review's Texas household as an order test under both itemization settings. Co-Authored-By: Claude Opus 5.5 --- ..._claim_of_right_section_1341_properties.py | 90 ++++++++++++++++++- policyengine_us/tools/period_branch.py | 32 ++++--- .../deductions/tax_liability_if_itemizing.py | 11 ++- .../tax_liability_if_not_itemizing.py | 11 ++- 4 files changed, 120 insertions(+), 24 deletions(-) diff --git a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py index d5095f249e4..e4cf5bba914 100644 --- a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py +++ b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py @@ -20,7 +20,9 @@ no repayment at all less the prior-year decrease; - results do not depend on which variable is calculated first, including when a tax liability under one itemizing choice is calculated before the - method is known; + method is known, under both settings of + ``gov.simulation.branch_to_determine_itemization``; the itemizing branches + keep the simulation's method; - a later year calculated on the same simulation equals a simulation of that year alone; - relations between cases over a grid: tax never rises with the repayment @@ -40,12 +42,15 @@ California's, Kansas's and Wisconsin's unchanged. """ +import gc import itertools import numpy as np import pytest +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform -from policyengine_us import Simulation +from policyengine_us import CountryTaxBenefitSystem, Simulation from policyengine_us.variables.household.demographic.geographic.state_code import ( StateCode, ) @@ -328,3 +333,84 @@ def test_states_under_each_federal_method(): np.testing.assert_allclose( with_deduction[excluded], without_repayment[excluded], atol=TOLERANCE ) + + +# The household from reviews r1 and r2 of PolicyEngine/policyengine-us#10038. +# With the deduction, $20,000 of itemized deductions leave $40,000 of taxable +# income: $1,192.50 + 12% x $28,075 = $4,561.50. Without it, the $15,750 +# standard deduction leaves $44,250: $5,071.50, less the $100 decrease is +# $4,971.50. So the deduction applies, and tax without itemizing under that +# method is $5,071.50. +TEXAS_YEAR = 2025 +TEXAS_EXPECTED = { + "income_tax": 4_561.5, + "claim_of_right_credit_applies": False, + "tax_liability_if_itemizing": 4_561.5, + "tax_liability_if_not_itemizing": 5_071.5, + "income_tax_if_claiming_claim_of_right_deduction": 4_561.5, + "income_tax_if_claiming_claim_of_right_credit": 4_971.5, +} + + +class no_itemization_branching(Reform): + def apply(self): + def modify(parameters): + parameters.gov.simulation.branch_to_determine_itemization.update( + start=instant(f"{TEXAS_YEAR}-01-01"), + stop=instant(f"{TEXAS_YEAR}-12-31"), + value=False, + ) + return parameters + + self.modify_parameters(modify) + + +@pytest.fixture(scope="module") +def systems(): + yield { + True: None, + False: CountryTaxBenefitSystem(reform=(no_itemization_branching,)), + } + gc.collect() + + +def texas_simulation(system): + year = TEXAS_YEAR + situation = { + "people": { + "p": { + "age": {year: 40}, + "employment_income": {year: 60_000}, + "charitable_cash_donations": {year: 10_000}, + "claim_of_right_repayment": {year: 10_000}, + } + }, + "tax_units": { + "t": { + "members": ["p"], + "claim_of_right_prior_year_tax_decrease": {year: 100}, + "state_sales_tax": {year: 0}, + "local_sales_tax": {year: 0}, + } + }, + "households": {"h": {"members": ["p"], "state_code": {year: "TX"}}}, + } + if system is None: + return Simulation(situation=situation) + return Simulation(tax_benefit_system=system, situation=situation) + + +@pytest.mark.parametrize("branching", [True, False]) +@pytest.mark.parametrize("first", list(TEXAS_EXPECTED)) +def test_texas_household_does_not_depend_on_calculation_order( + systems, branching, first +): + simulation = texas_simulation(systems[branching]) + simulation.calculate(first, TEXAS_YEAR) + for variable, expected in TEXAS_EXPECTED.items(): + np.testing.assert_allclose( + np.asarray(simulation.calculate(variable, TEXAS_YEAR)), + [expected], + atol=TOLERANCE, + err_msg=f"{variable} when {first} is calculated first", + ) diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 7cf5f4c8d2d..4d659e82400 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -17,13 +17,13 @@ from the parent as the parent stands then, as a simulation calculating only that period would create it. - When the branch is created, it keeps the parent's cache only if the parent - has no value yet for any overridden variable and period. A cached value - cannot have been calculated from a value that did not exist, so the - parent's cache is then safe to share; this is the usual case, where a - formula branches while its parent is still calculating the variable the - branch overrides. Otherwise the branch drops every array it copied except - inputs, each for the periods it was set for, and calculates the rest - itself. + has no value yet for any overridden variable and period, or has exactly + the override value. A cached value cannot have been calculated from a + value that did not exist, or from a different one, so the parent's cache + is then safe to share; this is the usual case, where a formula branches + while its parent is still calculating the variable the branch overrides. + Otherwise the branch drops every array it copied except inputs, each for + the periods it was set for, and calculates the rest itself. - A branch reused within its period with different inputs is created again. - A caller can ask the branch never to keep the parent's calculated values (``inherit_calculated=False``). The parent can hold a value that depends on @@ -56,20 +56,24 @@ def _overrides(period: Period, inputs: Dict[str, Override]): yield variable, period, np.asarray(value) -def _is_known(simulation: Simulation, variable: str, period: Period) -> bool: +def _known_otherwise( + simulation: Simulation, variable: str, period: Period, value: np.ndarray +) -> bool: + """Whether ``simulation`` holds a value for ``variable`` other than ``value``.""" holder = simulation.get_holder(variable) if holder.variable.is_neutralized: return False - return holder.get_array(period, simulation.branch_name) is not None + known = holder.get_array(period, simulation.branch_name) + return known is not None and not np.array_equal(known, value) def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: """The (variable, branch name, period) keys ``branch`` reads as inputs. policyengine-core records each key that ``set_input`` stores, whether - from the dataset, the situation or a branch, in one set shared by a - simulation and all its branches. ``branch`` reads its own keys and its - ancestors'. + from the dataset, the situation or a branch. A branch starts with a copy + of its parent's keys (``Simulation.clone``) and adds its own. ``branch`` + reads its own keys and its ancestors'. """ visible_branches = set(branch._get_visible_branch_names()) return { @@ -142,8 +146,8 @@ def get_override_branch( branch = None if branch is None: parent_knows_override = any( - _is_known(simulation, variable, input_period) - for variable, input_period, _ in overrides + _known_otherwise(simulation, variable, input_period, value) + for variable, input_period, value in overrides ) branch = simulation.get_branch(name) branch.branch_period = period diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py index b4aa7f73265..8d2fcd59a2f 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py @@ -10,13 +10,16 @@ class tax_liability_if_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - # Settle the claim of right method (26 U.S.C. 1341) in this simulation - # first, so that the branch uses it and does not choose its own. - tax_unit("claim_of_right_credit_applies", period) + # The branch keeps this simulation's claim of right method (26 U.S.C. + # 1341) rather than choosing its own, whatever is calculated first. + claim_of_right_method = tax_unit("claim_of_right_credit_applies", period) itemized_branch = get_override_branch( simulation, "itemizing", period, - {"tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool)}, + { + "tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool), + "claim_of_right_credit_applies": claim_of_right_method, + }, ) return itemized_branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py index 0540ebc3652..6c2ebfcd7e7 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py @@ -11,13 +11,16 @@ class tax_liability_if_not_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - # Settle the claim of right method (26 U.S.C. 1341) in this simulation - # first, so that the branch uses it and does not choose its own. - tax_unit("claim_of_right_credit_applies", period) + # The branch keeps this simulation's claim of right method (26 U.S.C. + # 1341) rather than choosing its own, whatever is calculated first. + claim_of_right_method = tax_unit("claim_of_right_credit_applies", period) non_itemized_branch = get_override_branch( simulation, "not_itemizing", period, - {"tax_unit_itemizes": np.zeros((tax_unit.count,), dtype=bool)}, + { + "tax_unit_itemizes": np.zeros((tax_unit.count,), dtype=bool), + "claim_of_right_credit_applies": claim_of_right_method, + }, ) return non_itemized_branch.calculate("income_tax", period) From 9c98f0d90a0f2dabccd2d8c087a020730d218e3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 11:37:16 -0400 Subject: [PATCH 10/11] Restore conservative branch invalidation; fix the method in itemizing branches only when someone qualifies Review r3 showed that keeping a parent's cache when an override equals the parent's current value can reuse tax calculated before another input changed. Branches again drop inherited values whenever the parent knows an overridden variable. The itemizing branches add the claim of right method as an override only when some tax unit repays more than $3,000, so simulations without a qualifying repayment keep sharing the parent's cache. Adds the review's stale-parent sequence as a test. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_override_branches.py | 5 ++- ..._claim_of_right_section_1341_properties.py | 45 ++++++++++++++++++- policyengine_us/tools/period_branch.py | 27 +++++------ .../deductions/tax_liability_if_itemizing.py | 17 ++++--- .../tax_liability_if_not_itemizing.py | 17 ++++--- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/policyengine_us/tests/core/test_override_branches.py b/policyengine_us/tests/core/test_override_branches.py index 9e46a63644e..7abea0e424a 100644 --- a/policyengine_us/tests/core/test_override_branches.py +++ b/policyengine_us/tests/core/test_override_branches.py @@ -29,7 +29,10 @@ on the seeded sample and on random households (Hypothesis). 3. Each comparison branch equals a fresh simulation that sets the overridden input before anything is calculated (a differential test of the branch - against the reference path). + against the reference path). These households repay no claim of right + income; with a qualifying repayment the itemizing branches also fix the + claim of right method, so a reference must fix it too + (``test_claim_of_right_section_1341_properties.py`` covers that case). 4. The same holds when the parent has already calculated the overridden input, and when an earlier year was calculated first. 5. When the branch drops what it copied, it keeps exactly the values set as diff --git a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py index e4cf5bba914..574705f8640 100644 --- a/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py +++ b/policyengine_us/tests/unit/test_claim_of_right_section_1341_properties.py @@ -24,7 +24,8 @@ ``gov.simulation.branch_to_determine_itemization``; the itemizing branches keep the simulation's method; - a later year calculated on the same simulation equals a simulation of that - year alone; + year alone, and an itemizing branch of a simulation whose itemizing input + changed after its tax was calculated does not reuse that tax; - relations between cases over a grid: tax never rises with the repayment (also across the $3,000 threshold) or with the prior-year decrease, and a repayment of $3,000 or less changes nothing (section 67(h), formerly 67(g)). @@ -367,10 +368,13 @@ def modify(parameters): @pytest.fixture(scope="module") def systems(): - yield { + systems = { True: None, False: CountryTaxBenefitSystem(reform=(no_itemization_branching,)), } + yield systems + # Release the reformed system before the rest of the test process runs. + systems.clear() gc.collect() @@ -414,3 +418,40 @@ def test_texas_household_does_not_depend_on_calculation_order( atol=TOLERANCE, err_msg=f"{variable} when {first} is calculated first", ) + + +def test_a_branch_does_not_reuse_tax_calculated_before_an_input_changed(): + # Review r3 of PolicyEngine/policyengine-us#10038. No repayment, $20,000 of + # charitable gifts: $5,071.50 with the $15,750 standard deduction (taxable + # income $44,250), $4,561.50 itemizing (taxable income $40,000). + year = TEXAS_YEAR + simulation = Simulation( + situation={ + "people": { + "p": { + "age": {year: 40}, + "employment_income": {year: 60_000}, + "charitable_cash_donations": {year: 20_000}, + } + }, + "tax_units": { + "t": { + "members": ["p"], + "tax_unit_itemizes": {year: False}, + "state_sales_tax": {year: 0}, + "local_sales_tax": {year: 0}, + } + }, + "households": {"h": {"members": ["p"], "state_code": {year: "TX"}}}, + } + ) + np.testing.assert_allclose( + simulation.calculate("income_tax", year), [5_071.5], atol=TOLERANCE + ) + # A plain branch whose itemizing input now equals the itemizing branch's + # override still holds the tax calculated without itemizing. + branch = simulation.get_branch("itemizes_after_the_fact") + branch.set_input("tax_unit_itemizes", year, np.array([True])) + np.testing.assert_allclose( + branch.calculate("tax_liability_if_itemizing", year), [4_561.5], atol=TOLERANCE + ) diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 4d659e82400..2f606c1b7c3 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -17,13 +17,14 @@ from the parent as the parent stands then, as a simulation calculating only that period would create it. - When the branch is created, it keeps the parent's cache only if the parent - has no value yet for any overridden variable and period, or has exactly - the override value. A cached value cannot have been calculated from a - value that did not exist, or from a different one, so the parent's cache - is then safe to share; this is the usual case, where a formula branches - while its parent is still calculating the variable the branch overrides. - Otherwise the branch drops every array it copied except inputs, each for - the periods it was set for, and calculates the rest itself. + has no value yet for any overridden variable and period. A cached value + cannot have been calculated from a value that did not exist, so the + parent's cache is then safe to share; this is the usual case, where a + formula branches while its parent is still calculating the variable the + branch overrides. Otherwise the branch drops every array it copied except + inputs, each for the periods it was set for, and calculates the rest + itself. A parent value equal to the override is not enough: the parent's + cache can predate a later change to another input. - A branch reused within its period with different inputs is created again. - A caller can ask the branch never to keep the parent's calculated values (``inherit_calculated=False``). The parent can hold a value that depends on @@ -56,15 +57,11 @@ def _overrides(period: Period, inputs: Dict[str, Override]): yield variable, period, np.asarray(value) -def _known_otherwise( - simulation: Simulation, variable: str, period: Period, value: np.ndarray -) -> bool: - """Whether ``simulation`` holds a value for ``variable`` other than ``value``.""" +def _is_known(simulation: Simulation, variable: str, period: Period) -> bool: holder = simulation.get_holder(variable) if holder.variable.is_neutralized: return False - known = holder.get_array(period, simulation.branch_name) - return known is not None and not np.array_equal(known, value) + return holder.get_array(period, simulation.branch_name) is not None def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: @@ -146,8 +143,8 @@ def get_override_branch( branch = None if branch is None: parent_knows_override = any( - _known_otherwise(simulation, variable, input_period, value) - for variable, input_period, value in overrides + _is_known(simulation, variable, input_period) + for variable, input_period, _ in overrides ) branch = simulation.get_branch(name) branch.branch_period = period diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py index 8d2fcd59a2f..984e9f60aa8 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_itemizing.py @@ -10,16 +10,19 @@ class tax_liability_if_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - # The branch keeps this simulation's claim of right method (26 U.S.C. - # 1341) rather than choosing its own, whatever is calculated first. - claim_of_right_method = tax_unit("claim_of_right_credit_applies", period) + overrides = {"tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool)} + if tax_unit("claim_of_right_section_1341_eligible", period).any(): + # Keep this simulation's claim of right method (26 U.S.C. 1341) + # rather than letting the branch choose its own. With no + # qualifying repayment the method is false everywhere, and + # leaving it out keeps the branch sharing this simulation's cache. + overrides["claim_of_right_credit_applies"] = tax_unit( + "claim_of_right_credit_applies", period + ) itemized_branch = get_override_branch( simulation, "itemizing", period, - { - "tax_unit_itemizes": np.ones((tax_unit.count,), dtype=bool), - "claim_of_right_credit_applies": claim_of_right_method, - }, + overrides, ) return itemized_branch.calculate("income_tax", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py index 6c2ebfcd7e7..a6c74288e0e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/tax_liability_if_not_itemizing.py @@ -11,16 +11,19 @@ class tax_liability_if_not_itemizing(Variable): def formula(tax_unit, period, parameters): simulation = tax_unit.simulation - # The branch keeps this simulation's claim of right method (26 U.S.C. - # 1341) rather than choosing its own, whatever is calculated first. - claim_of_right_method = tax_unit("claim_of_right_credit_applies", period) + overrides = {"tax_unit_itemizes": np.zeros((tax_unit.count,), dtype=bool)} + if tax_unit("claim_of_right_section_1341_eligible", period).any(): + # Keep this simulation's claim of right method (26 U.S.C. 1341) + # rather than letting the branch choose its own. With no + # qualifying repayment the method is false everywhere, and + # leaving it out keeps the branch sharing this simulation's cache. + overrides["claim_of_right_credit_applies"] = tax_unit( + "claim_of_right_credit_applies", period + ) non_itemized_branch = get_override_branch( simulation, "not_itemizing", period, - { - "tax_unit_itemizes": np.zeros((tax_unit.count,), dtype=bool), - "claim_of_right_credit_applies": claim_of_right_method, - }, + overrides, ) return non_itemized_branch.calculate("income_tax", period) From dc10cc58ce7f87daf901b23e66a54f328b3f34a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:18:15 -0400 Subject: [PATCH 11/11] Test both NC contrib credits stacked on the claim of right payment with use tax Co-Authored-By: Claude Opus 5.5 --- .../contrib/states/nc/cdcc/nc_cdcc.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml b/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml index e0e8d6353f7..8a0ab67fdcb 100644 --- a/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml +++ b/policyengine_us/tests/policy/contrib/states/nc/cdcc/nc_cdcc.yaml @@ -115,3 +115,29 @@ nc_cdcc: 300 # 600 - 300 = 300; it would be 400 if use tax were added. nc_income_tax: 300 + +- name: Both NC contrib credits stack on the claim of right payment, and use tax stays out of income tax + # Review r2 of PolicyEngine/policyengine-us#10038. Refundable credits: + # 250 payment + 100 EITC (10% of 1,000) + 300 CDCC (30% of 1,000) = 650. + period: 2026 + reforms: + - policyengine_us.reforms.states.nc.eitc.nc_eitc + - policyengine_us.reforms.states.nc.cdcc.nc_cdcc + input: + gov.contrib.states.nc.eitc.match: 0.1 + gov.contrib.states.nc.cdcc.in_effect: true + state_code: NC + eitc: 1_000 + cdcc_potential: 1_000 + nc_claim_of_right_payment: 250 + nc_income_tax_before_credits: 600 + nc_use_tax: 100 + nc_non_refundable_credits: 0 + output: + nc_refundable_credits: 650 + # 600 - 650; the 100 of use tax is counted once, through state_use_tax. + nc_income_tax: -50 + state_income_tax: -50 + state_use_tax: 100 + household_state_tax_before_refundable_credits: 700 + household_refundable_state_tax_credits: 650