diff --git a/changelog.d/ut-taxpayer-credit-sales-tax.fixed.md b/changelog.d/ut-taxpayer-credit-sales-tax.fixed.md new file mode 100644 index 00000000000..52d9a540b75 --- /dev/null +++ b/changelog.d/ut-taxpayer-credit-sales-tax.fixed.md @@ -0,0 +1 @@ +Keep itemized sales taxes, including a sales-tax election shown by a supplied sales-or-income amount, in Utah's taxpayer credit base; Utah Code 59-10-1018 removes only state and local income taxes. diff --git a/policyengine_us/tests/core/test_ut_taxpayer_credit_sales_tax_properties.py b/policyengine_us/tests/core/test_ut_taxpayer_credit_sales_tax_properties.py new file mode 100644 index 00000000000..5f829b3f463 --- /dev/null +++ b/policyengine_us/tests/core/test_ut_taxpayer_credit_sales_tax_properties.py @@ -0,0 +1,202 @@ +"""Properties of the federal deductions in Utah's taxpayer credit base. + +Utah Code 59-10-1018 starts from the federal itemized deductions and removes +only state or local income tax: in (2)(a)(ii)(A) of the edition superseded on +May 8, 2018, which the 2017 draws use, and after that in the definition of +the Utah itemized deduction ((1)(f)(i) in the 2021, 2023 and 2026 editions). +A tax unit whose sales taxes strictly exceed its income taxes takes the +sales tax under the federal formula (state_and_local_sales_or_income_tax is +the larger of the two), so: + +1. Sales-tax election: nothing is removed. The base is never above the + supplied federal itemized total, and equals it; with no total supplied, + it equals the non-SALT itemized deductions plus the allowed SALT + deduction. +2. Income-tax election: the base never exceeds the larger of the federal + itemized total and the non-SALT deductions plus capped real-estate tax, + and it never falls below the latter. +3. Raising sales tax above income tax, with every other input fixed, never + lowers the base when the supplied total is not cut below the + reconstruction. + +Section 68 enters through the supplied total: the draws cut it by up to 80% +of the reconstructed total, which can leave it below the non-SALT deductions +plus the allowed SALT deduction. Before this change (1) and (3) failed +whenever the allowed SALT deduction exceeded capped real-estate taxes: the +sales tax was removed from the base. With max(itemized, non-SALT + SALT) as +the sales-tax base, (1) failed whenever the cut left the total below that +reconstruction. Each example builds one small household simulation holding +every drawn tax unit twice, once as drawn and once with sales tax raised +above income tax. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEARS = (2017, 2018, 2024, 2025, 2026) +FILING_STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD") +AMOUNT = st.integers(min_value=0, max_value=60_000) + +UNIT = st.fixed_dictionaries( + { + "filing_status": st.sampled_from(FILING_STATUSES), + "charitable": AMOUNT, + "real_estate": AMOUNT, + "income_tax": AMOUNT, + "sales_tax": AMOUNT, + "extra_itemized": AMOUNT, + # Percent of the reconstructed total that section 68 removes from the + # supplied federal itemized deductions (at most 80% before 2018). + "section_68_cut": st.integers(min_value=0, max_value=80), + "supply_itemized": st.booleans(), + } +) + + +def _salt_cap(filing_status, year): + # 26 U.S.C. 164(b)(6) and (b)(7) caps before any phase-down: none in + # 2017, $10,000 ($5,000 separate) from 2018 through 2024, $40,000 + # ($20,000) in 2025 and $40,400 ($20,200) in 2026. + full = { + 2017: np.inf, + 2018: 10_000, + 2024: 10_000, + 2025: 40_000, + 2026: 40_400, + }[year] + return full / 2 if filing_status == "SEPARATE" else full + + +def _salt(unit, year): + return min( + unit["real_estate"] + max(unit["income_tax"], unit["sales_tax"]), + _salt_cap(unit["filing_status"], year), + ) + + +def _itemized(unit, year): + if not unit["supply_itemized"]: + return 0 + total = unit["charitable"] + _salt(unit, year) + unit["extra_itemized"] + # Whole dollars keep the values exact in float32. + return total - total * unit["section_68_cut"] // 100 + + +def _situation(year, units): + period = str(year) + people, tax_units, households = {}, {}, {} + for i, unit in enumerate(units): + name = f"p{i}" + people[name] = { + "age": {period: 40}, + "real_estate_taxes": {period: unit["real_estate"]}, + } + tax_units[f"t{i}"] = { + "members": [name], + "filing_status": {period: unit["filing_status"]}, + "tax_unit_itemizes": {period: True}, + "charitable_deduction": {period: unit["charitable"]}, + "interest_deduction": {period: 0}, + "medical_expense_deduction": {period: 0}, + "casualty_loss_deduction": {period: 0}, + "misc_deduction": {period: 0}, + "state_withheld_income_tax": {period: unit["income_tax"]}, + "local_income_tax": {period: 0}, + "state_sales_tax": {period: unit["sales_tax"]}, + "local_sales_tax": {period: 0}, + "salt_deduction": {period: _salt(unit, year)}, + "itemized_taxable_income_deductions": {period: _itemized(unit, year)}, + } + households[f"h{i}"] = {"members": [name], "state_code": {period: "UT"}} + return {"people": people, "tax_units": tax_units, "households": households} + + +def _with_sales_election(unit): + return {**unit, "sales_tax": unit["income_tax"] + 1 + unit["sales_tax"]} + + +@settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + year=st.sampled_from(YEARS), + units=st.lists(UNIT, min_size=4, max_size=12), +) +# Section 68 cuts the supplied total below the reconstruction under both +# elections: $15,000 - 10% = $13,500 against $6,000 + $9,000 for the first +# unit, so (1) holds only if the base stays at $13,500. +@example( + year=2026, + units=[ + { + "filing_status": "JOINT", + "charitable": 6_000, + "real_estate": 0, + "income_tax": 0, + "sales_tax": 9_000, + "extra_itemized": 0, + "section_68_cut": 10, + "supply_itemized": True, + }, + { + "filing_status": "SINGLE", + "charitable": 6_000, + "real_estate": 4_000, + "income_tax": 9_000, + "sales_tax": 2_000, + "extra_itemized": 0, + "section_68_cut": 10, + "supply_itemized": True, + }, + ], +) +def test_utah_credit_base_keeps_sales_tax(year, units): + paired = units + [_with_sales_election(unit) for unit in units] + situation = _situation(year, paired) + sim = Simulation(situation=situation) + base = sim.calculate("ut_federal_deductions_for_taxpayer_credit", year) + period = str(year) + tu = situation["tax_units"] + itemized = np.array( + [ + tu[f"t{i}"]["itemized_taxable_income_deductions"][period] + for i in range(len(paired)) + ] + ) + salt = np.array([tu[f"t{i}"]["salt_deduction"][period] for i in range(len(paired))]) + other = np.array([u["charitable"] for u in paired]) + real_estate = np.array([u["real_estate"] for u in paired]) + cap = np.array([_salt_cap(u["filing_status"], year) for u in paired]) + sales_elected = np.array([u["sales_tax"] > u["income_tax"] for u in paired]) + + # (1) Sales-tax election removes nothing: never above the supplied + # federal itemized total, and equal to it, or to the fallback when no + # total is supplied. + supplied = sales_elected & (itemized > 0) + assert np.all(base[supplied] <= itemized[supplied] + 1e-6) + assert np.allclose(base[supplied], itemized[supplied]) + not_supplied = sales_elected & (itemized == 0) + assert np.allclose(base[not_supplied], (other + salt)[not_supplied]) + + # (2) Income-tax election stays within its bounds. + income_floor = other + np.minimum(real_estate, cap) + income = ~sales_elected + assert np.all(base[income] >= income_floor[income] - 1e-6) + assert np.all(base[income] <= np.maximum(itemized, income_floor)[income] + 1e-6) + + # (3) Moving to a sales-tax election never lowers the base. A pair whose + # supplied total is cut is left out: the income-tax branch floors its + # base at the reconstruction before section 68, which the sales-tax + # branch does not. + n = len(units) + uncut = np.array( + [u["section_68_cut"] == 0 or not u["supply_itemized"] for u in units], + dtype=bool, + ) + assert np.all(base[n:][uncut] >= base[:n][uncut] - 1e-6) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_federal_deductions_for_taxpayer_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_federal_deductions_for_taxpayer_credit.yaml index 0b633991a8b..f9c9a1f39b3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_federal_deductions_for_taxpayer_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_federal_deductions_for_taxpayer_credit.yaml @@ -59,15 +59,274 @@ output: ut_federal_deductions_for_taxpayer_credit: 5_000 -- name: "Case 5: Utah subtracts only state income or sales tax from claimed itemized deductions" +- name: "Case 5: Utah subtracts state income tax from claimed itemized deductions" + # An income-tax election: the $9,000 income tax is removed. A sales-tax + # election keeps its sales tax in the base (Cases 6, 7 and 10). period: 2023 input: filing_status: JOINT state_code: UT tax_unit_itemizes: true itemized_taxable_income_deductions: 15_000 + state_withheld_income_tax: 9_000 + local_income_tax: 0 + state_sales_tax: 0 + local_sales_tax: 0 state_and_local_sales_or_income_tax: 9_000 standard_deduction: 0 output: ut_federal_deductions_for_taxpayer_credit: 6_000 + +- name: "Case 6: allowed sales taxes remain in the Utah credit base" + # Utah Code 59-10-1018(1)(f)(i) removes only state or local income tax from + # the federal itemized total. $6,000 other deductions + $9,000 sales tax. + period: 2025 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 15_000 + charitable_deduction: 6_000 + real_estate_taxes: 0 + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 9_000 + local_sales_tax: 0 + salt_deduction: 9_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 15_000 + # 6% x $15,000 + ut_taxpayer_credit_max: 900 + +- name: "Case 7: capped sales taxes still remain in the Utah credit base" + # Allowed SALT $10,000 (real estate $4,000 + sales $6,000 under the cap) + # + other deductions $6,000 = $16,000. + period: 2024 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 16_000 + charitable_deduction: 6_000 + real_estate_taxes: 4_000 + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 9_000 + local_sales_tax: 0 + salt_deduction: 10_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 16_000 + # 6% x $16,000 + ut_taxpayer_credit_max: 960 + +- name: "Case 8: income taxes are still removed when they exceed sales taxes" + # Income-tax election: $15,000 - $9,000 income tax = $6,000. + period: 2025 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 15_000 + charitable_deduction: 6_000 + real_estate_taxes: 0 + state_withheld_income_tax: 9_000 + local_income_tax: 0 + state_sales_tax: 2_000 + local_sales_tax: 0 + salt_deduction: 9_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 6_000 + +- name: "Case 9: equal sales and income taxes follow the income-tax branch" + # The federal SALT amount equals both taxes, so it doesn't show which one + # the return deducted; the conservative reading removes it as income tax. + period: 2025 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 15_000 + charitable_deduction: 6_000 + real_estate_taxes: 0 + state_withheld_income_tax: 9_000 + local_income_tax: 0 + state_sales_tax: 9_000 + local_sales_tax: 0 + salt_deduction: 9_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 6_000 + +- name: "Case 10: sales-tax fallback when no federal itemized total is supplied" + # Fallback base: non-SALT itemized deductions ($6,000) + allowed SALT ($9,000). + period: 2025 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 0 + charitable_deduction: 6_000 + real_estate_taxes: 0 + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 9_000 + local_sales_tax: 0 + salt_deduction: 9_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 15_000 + +- name: "Case 11: a federal itemized total below the reconstruction is kept under a sales-tax election" + # Section 68 can leave the federal itemized deductions ($14,000 here) below + # the non-SALT deductions plus allowed SALT ($6,000 + $9,000 = $15,000). + # A sales-tax election removes nothing, so the base is $14,000. + period: 2026 + input: + state_code: UT + filing_status: JOINT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 14_000 + charitable_deduction: 6_000 + real_estate_taxes: 0 + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 9_000 + local_sales_tax: 0 + salt_deduction: 9_000 + ut_personal_exemption: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 14_000 + # 6% x $14,000 + ut_taxpayer_credit_max: 840 + +- name: "Case 12: a supplied sales-or-income amount matching neither component follows the income-tax branch" + # The earlier Case 5 inputs. The supplied $9,000 is neither the formula's + # sales tax nor its income tax ($0 with no income), so the unit is not a + # sales-tax election and the $9,000 is removed: $15,000 - $9,000 = $6,000. + period: 2023 + input: + filing_status: JOINT + state_code: UT + tax_unit_itemizes: true + itemized_taxable_income_deductions: 15_000 + state_and_local_sales_or_income_tax: 9_000 + standard_deduction: 0 + output: + ut_federal_deductions_for_taxpayer_credit: 6_000 + +- name: "Case 13: 2026 sales-tax election keeps the section 68 reduction" + # Single filer: $1.5M long-term capital gains (AGI $1,500,000), $100,000 + # cash to charity, $20,000 state sales tax and no income tax. + # Charitable: $100,000 - 0.5% x $1,500,000 floor = $92,500. + # SALT cap: $40,400 - 30% x ($1,500,000 - $505,000) is below the $10,000 + # floor, so allowed SALT is $10,000. Total: $92,500 + $10,000 = $102,500. + # Section 68: 2/37 x min($102,500, $1,500,000 - $640,600) = $5,540.54. + # Federal itemized deductions: $102,500 - $5,540.54 = $96,959.46, and a + # sales-tax election removes nothing from them. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + long_term_capital_gains: 1_500_000 + charitable_cash_donations: 100_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + tax_unit_itemizes: true + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 20_000 + local_sales_tax: 0 + households: + household: + members: [head] + state_code: UT + output: + total_itemized_taxable_income_deductions: 102_500 + itemized_taxable_income_deductions_reduction: 5_540.54 + itemized_taxable_income_deductions: 96_959.46 + ut_federal_deductions_for_taxpayer_credit: 96_959.46 + +- name: "Case 14: 2017 sales-tax election keeps the section 68 reduction" + # The Case 13 filer in 2017: no SALT cap and no charitable floor, so the + # total is $100,000 + $20,000 = $120,000. + # Section 68: min(3% x ($1,500,000 - $261,500), 80% x $120,000) + # = min($37,155, $96,000) = $37,155. + # Federal itemized deductions: $120,000 - $37,155 = $82,845, and a + # sales-tax election removes nothing from them: the 2017 text, + # 59-10-1018(2)(a)(ii)(A)(II), removes only state or local income taxes. + period: 2017 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + long_term_capital_gains: 1_500_000 + charitable_cash_donations: 100_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + tax_unit_itemizes: true + state_withheld_income_tax: 0 + local_income_tax: 0 + state_sales_tax: 20_000 + local_sales_tax: 0 + households: + household: + members: [head] + state_code: UT + output: + total_itemized_taxable_income_deductions: 120_000 + itemized_taxable_income_deductions_reduction: 37_155 + itemized_taxable_income_deductions: 82_845 + ut_federal_deductions_for_taxpayer_credit: 82_845 + +- name: "Case 15: a supplied sales-tax election stands when the sales tax is below the income tax" + # The supplied $3,600 federal SALT amount equals the sales tax and not the + # $3,700 income tax, so the return deducted sales tax (Schedule A line 5a) + # and reports no income tax. 59-10-1018(1)(e)-(f) removes only income tax + # the claimant reports on the federal return, so nothing is removed. + # Federal itemized deductions: $36,000 + $3,600 = $39,600 (no section 68 + # reduction in 2025). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 80_000 + spouse: + age: 40 + tax_units: + tax_unit: + members: [head, spouse] + tax_unit_itemizes: true + charitable_deduction: 36_000 + state_withheld_income_tax: 3_700 + local_income_tax: 0 + state_sales_tax: 3_600 + local_sales_tax: 0 + state_and_local_sales_or_income_tax: 3_600 + households: + household: + members: [head, spouse] + state_code: UT + output: + salt_deduction: 3_600 + itemized_taxable_income_deductions: 39_600 + ut_federal_deductions_for_taxpayer_credit: 39_600 + # TC-40 line 16: 6% x $39,600. + ut_taxpayer_credit_max: 2_376 + # Lines 17 to 19: 1.3% x ($80,000 - $36,426) = $566.46. Line 20: + # $2,376 - $566.46. Before this fix the base was $36,000, the credit + # maximum $2,160 and the credit $1,593.54, $216 less. + ut_taxpayer_credit_reduction: 566.46 + ut_taxpayer_credit: 1_809.54 diff --git a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_federal_deductions_for_taxpayer_credit.py b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_federal_deductions_for_taxpayer_credit.py index 34ffefdcdeb..cd915f94c18 100644 --- a/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_federal_deductions_for_taxpayer_credit.py +++ b/policyengine_us/variables/gov/states/ut/tax/income/credits/taxpayer_credit/ut_federal_deductions_for_taxpayer_credit.py @@ -9,7 +9,22 @@ class ut_federal_deductions_for_taxpayer_credit(Variable): documentation = "These federal deductions are added to the Utah personal exemption to determine the Utah taxpayer credit." definition_period = YEAR defined_for = StateCode.UT - reference = "https://le.utah.gov/xcode/Title59/Chapter10/59-10-S114.html?v=C59-10-S114_2022032320220323" + reference = ( + # Utah Code 59-10-1018(2)(a)(ii)(A), the edition superseded on May 8, + # 2018: the federal itemized deduction less the state or local income + # taxes deducted as an itemized deduction. + "https://le.utah.gov/xcode/Title59/Chapter10/C59-10-S1018_1800010118000101.html", + # Utah Code 59-10-1018(1)(e) and (1)(f)(i), 2023 edition: only state + # or local income tax is removed from the federal itemized total. The + # 2021 edition reads the same, and the edition effective May 8, 2018 + # has the text at (1)(g) and (1)(h)(i). + "https://le.utah.gov/xcode/Title59/Chapter10/C59-10-S1018_2023050320230503.html", + # Utah Code 59-10-1018(1)(e) and (1)(f)(i), the edition for tax years + # from 2026. + "https://le.utah.gov/xcode/Title59/Chapter10/C59-10-S1018_2026050620260506.pdf#page=1", + # 2025 TC-40 instructions, line 14: do not remove itemized sales tax. + "https://files.tax.utah.gov/tax/forms/2025/tc-40inst.pdf#page=10", + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.irs.deductions @@ -21,6 +36,17 @@ def formula(tax_unit, period, parameters): raw_state_income_or_sales_tax = tax_unit( "state_and_local_sales_or_income_tax", period ) + income_tax = add( + tax_unit, period, ["state_withheld_income_tax", "local_income_tax"] + ) + sales_tax = add(tax_unit, period, ["state_sales_tax", "local_sales_tax"]) + # Sales-tax election only where the SALT amount the federal side used + # equals the sales tax and not the income tax, whether the formula + # (the larger of the two) or an input supplied it. A tie or a supplied + # amount that matches neither keeps the income-tax treatment. + sales_tax_elected = np.isclose( + raw_state_income_or_sales_tax, sales_tax + ) & ~np.isclose(raw_state_income_or_sales_tax, income_tax) deductions = [ deduction for deduction in p.itemized_deductions @@ -34,14 +60,26 @@ def formula(tax_unit, period, parameters): deducted_state_income_or_sales_tax = max_( salt_deduction - capped_real_estate_tax, 0 ) - fallback_base = fallback_itemized + capped_real_estate_tax - explicit_itemized_override = itemized > fallback_base - claimed_state_income_or_sales_tax = where( + # Income-tax election: remove the income tax, keep real-estate taxes. + income_fallback_base = fallback_itemized + capped_real_estate_tax + explicit_itemized_override = itemized > income_fallback_base + claimed_state_income_tax = where( salt_deduction > 0, deducted_state_income_or_sales_tax, where(explicit_itemized_override, raw_state_income_or_sales_tax, 0), ) - claimed_base = max_(itemized - claimed_state_income_or_sales_tax, fallback_base) + income_claimed_base = max_( + itemized - claimed_state_income_tax, income_fallback_base + ) + # Sales-tax election: 59-10-1018 removes no sales tax. The base is the + # federal itemized deductions after section 68; when that is zero, the + # fallback is the non-SALT deductions plus the allowed SALT deduction. + sales_fallback_base = fallback_itemized + salt_deduction + sales_claimed_base = itemized + fallback_base = where( + sales_tax_elected, sales_fallback_base, income_fallback_base + ) + claimed_base = where(sales_tax_elected, sales_claimed_base, income_claimed_base) itemized_base = where(itemized > 0, claimed_base, fallback_base) return where( us_itemizing,