From 0222cabda464396e023a4cd508fb1119de9c27d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 16:07:25 -0400 Subject: [PATCH 1/3] Cap Montana's 2021-2023 SALT deduction once per joint return and at $5,000 per separate column Form 2, Itemized Deductions Schedule, line 5 (2021-2023) adds lines 5a through 5d and caps the total at $10,000 (single, head of household, married filing jointly) or $5,000 (married filing separately). - mt_salt_deduction: a joint return's taxes are pooled and capped once, on the head. It had capped each spouse separately, so a couple with $8,000 each deducted $16,000. - mt_salt_deduction_indiv (new): each column of a return filed separately on the same form (status 2a) is capped at $5,000. The separate-columns path had used the tax unit's joint cap ($10,000). - Line 5a: add the tax unit's general sales taxes (state_sales_tax, local_sales_tax), split equally between a couple's columns. - From 2024 (schedule discontinued) the per-person amounts are as before; the 2024 Worksheet A change is separate. The 2021-2023 switch is gov.states.mt.tax.income.deductions.itemized. state_specific_deduction_applies, byte-identical to the file in #9903 and #9947. Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-salt-cap-joint-return.fixed.md | 1 + .../state_specific_deduction_applies.yaml | 16 + ...uctions_for_federal_itemization_indiv.yaml | 12 +- .../mt_itemized_deductions_indiv.yaml | 12 +- .../itemized/mt_salt_deduction.yaml | 92 ++++++ .../itemized/mt_salt_deduction_indiv.yaml | 149 +++++++++ .../test_mt_salt_deduction_invariants.py | 287 ++++++++++++++++++ ...eductions_for_federal_itemization_indiv.py | 2 +- .../general/mt_itemized_deductions_indiv.py | 2 +- .../itemized/salt/mt_salt_deduction.py | 45 ++- .../itemized/salt/mt_salt_deduction_indiv.py | 47 +++ 11 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fix-mt-salt-cap-joint-return.fixed.md create mode 100644 policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py diff --git a/changelog.d/fix-mt-salt-cap-joint-return.fixed.md b/changelog.d/fix-mt-salt-cap-joint-return.fixed.md new file mode 100644 index 00000000000..925a0a126f2 --- /dev/null +++ b/changelog.d/fix-mt-salt-cap-joint-return.fixed.md @@ -0,0 +1 @@ +Cap Montana's 2021โ€“2023 state and local tax deduction (Itemized Deductions Schedule, line 5) once for a joint return and at $5,000 in each spouse's column when filing separately on the same form, and include general sales taxes (line 5a). diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml new file mode 100644 index 00000000000..8b6d63360ab --- /dev/null +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml @@ -0,0 +1,16 @@ +description: Montana computes itemized deductions on its own Itemized Deductions Schedule, in lieu of adjusted federal itemized deductions, if this is true. +metadata: + label: Montana state specific itemized deductions apply + period: year + unit: bool + reference: + - title: Montana Individual Income Tax Return (Form 2) 2023 | Itemized Deductions Schedule + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 + - title: 2024 Montana Form 2 Instructions | Discontinued Worksheets and Schedules + 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: 2024 Montana Form 2 Instructions | Line 2 and Worksheet A, Adjusted Federal Itemized Deductions + 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=15 + +values: + 2021-01-01: true + 2024-01-01: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.yaml index 4435e64a801..47b6f0f8a7c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.yaml @@ -3,7 +3,7 @@ input: charitable_deduction: 800 investment_interest_expense: 265 - mt_salt_deduction: 5_000 + mt_salt_deduction_indiv: 5_000 mt_medical_expense_deduction_indiv: 1_000 casualty_loss_deduction: 400 mt_child_dependent_care_expense_deduction: 123 @@ -17,7 +17,7 @@ input: charitable_deduction: 0 investment_interest_expense: 1_234 - mt_salt_deduction: 10_000 + mt_salt_deduction_indiv: 10_000 mt_medical_expense_deduction_indiv: 3_000 casualty_loss_deduction: 0 mt_child_dependent_care_expense_deduction: 0 @@ -31,7 +31,7 @@ input: charitable_deduction: 1_000 mortgage_interest: 34 - mt_salt_deduction: 1_035 + mt_salt_deduction_indiv: 1_035 mt_medical_expense_deduction_indiv: 3_000 casualty_loss_deduction: 745 mt_child_dependent_care_expense_deduction: 1_004 @@ -49,20 +49,20 @@ is_tax_unit_head_or_spouse: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 200 - mt_salt_deduction: 300 + mt_salt_deduction_indiv: 300 mt_federal_income_tax_deduction_for_federal_itemization_indiv: 400 person2: is_tax_unit_head_or_spouse: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 200 - mt_salt_deduction: 300 + mt_salt_deduction_indiv: 300 mt_federal_income_tax_deduction_for_federal_itemization_indiv: 100 person3: is_tax_unit_head_or_spouse: false is_tax_unit_dependent: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 12 - mt_salt_deduction: 200 + mt_salt_deduction_indiv: 200 mt_federal_income_tax_deduction_for_federal_itemization_indiv: 100 tax_units: tax_unit: diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_indiv.yaml index 2bc390f8ad3..c10c66239d5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_indiv.yaml @@ -3,7 +3,7 @@ input: charitable_deduction: 800 investment_interest_expense: 265 - mt_salt_deduction: 5_000 + mt_salt_deduction_indiv: 5_000 mt_medical_expense_deduction_indiv: 1_000 casualty_loss_deduction: 400 mt_child_dependent_care_expense_deduction: 123 @@ -17,7 +17,7 @@ input: charitable_deduction: 0 investment_interest_expense: 1_234 - mt_salt_deduction: 10_000 + mt_salt_deduction_indiv: 10_000 mt_medical_expense_deduction_indiv: 3_000 casualty_loss_deduction: 0 mt_child_dependent_care_expense_deduction: 0 @@ -31,7 +31,7 @@ input: charitable_deduction: 1_000 mortgage_interest: 34 - mt_salt_deduction: 1_035 + mt_salt_deduction_indiv: 1_035 mt_medical_expense_deduction_indiv: 3_000 casualty_loss_deduction: 745 mt_child_dependent_care_expense_deduction: 1_004 @@ -49,20 +49,20 @@ is_tax_unit_head_or_spouse: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 200 - mt_salt_deduction: 300 + mt_salt_deduction_indiv: 300 mt_federal_income_tax_deduction_indiv: 400 person2: is_tax_unit_head_or_spouse: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 200 - mt_salt_deduction: 300 + mt_salt_deduction_indiv: 300 mt_federal_income_tax_deduction_indiv: 100 person3: is_tax_unit_head_or_spouse: false is_tax_unit_dependent: true mt_misc_deductions: 100 mt_medical_expense_deduction_indiv: 12 - mt_salt_deduction: 200 + mt_salt_deduction_indiv: 200 mt_federal_income_tax_deduction_indiv: 100 tax_units: tax_unit: diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml index 1e3da2a4f1b..f06ff3db79d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml @@ -35,3 +35,95 @@ output: mt_salt_deduction: 10_000 +- name: Test case 5, head of household, higher than cap + period: 2022 + input: + filing_status: HEAD_OF_HOUSEHOLD + real_estate_taxes: 12_000 + state_code: MT + output: + mt_salt_deduction: 10_000 + +# Form 2, Itemized Deductions Schedule, line 5 (2021-2023): "Add lines 5a +# through 5d, enter the total here, but not more than $10,000 if your status +# is single, head of household or married filing jointly". A joint return has +# one column, so the couple's taxes are added and capped once. + +- name: Joint return caps the couple's combined state and local taxes once + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 8_000 + person2: + age: 45 + real_estate_taxes: 8_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5c: 8,000 + 8,000 = 16,000; line 5: min(16,000, 10,000). + mt_salt_deduction: [10_000, 0] + # No income, so the federal income tax deduction (line 4) is zero and + # line 5 is the only itemized deduction. + mt_itemized_deductions_joint: [10_000, 0] + +- name: Joint return below the cap deducts both spouses' taxes + period: 2022 + input: + people: + person1: + age: 45 + real_estate_taxes: 6_000 + person2: + age: 45 + real_estate_taxes: 2_500 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5c: 6,000 + 2,500 = 8,500, under the 10,000 cap. + mt_salt_deduction: [8_500, 0] + +- name: Joint return adds general sales taxes before the cap + period: 2021 + input: + people: + person1: + age: 45 + real_estate_taxes: 9_000 + person2: + age: 45 + tax_units: + tax_unit: + members: [person1, person2] + state_sales_tax: 1_500 + local_sales_tax: 300 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5a: 1,500 + 300 = 1,800; line 5c: 9,000. Line 5: min(10,800, 10,000). + mt_salt_deduction: [10_000, 0] + +- name: Single return adds general sales taxes below the cap + period: 2023 + input: + filing_status: SINGLE + real_estate_taxes: 2_000 + state_sales_tax: 1_500 + local_sales_tax: 300 + state_code: MT + output: + # Line 5a: 1,800; line 5c: 2,000. Line 5: 3,800, under the 10,000 cap. + mt_salt_deduction: 3_800 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml new file mode 100644 index 00000000000..a04cb3d1d85 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml @@ -0,0 +1,149 @@ +# Form 2, Itemized Deductions Schedule, line 5 (2021-2023): "Add lines 5a +# through 5d, enter the total here, but not more than $10,000 if your status +# is single, head of household or married filing jointly; or $5,000 if you are +# married filing separately." Spouses filing separately on the same form +# (filing status 2a) allocate between columns A and B, "with each column +# constituting its own return" (2023 instructions, PDF p. 12). So each +# spouse's column is capped at $5,000. + +- name: Each spouse's column is capped at the married filing separately amount + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 8_000 + person2: + age: 45 + real_estate_taxes: 8_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Column A: min(8,000, 5,000); column B: min(8,000, 5,000). + mt_salt_deduction_indiv: [5_000, 5_000] + # No income, so the federal income tax deduction (line 4) is zero and + # line 5 is each column's only itemized deduction. + mt_itemized_deductions_indiv: [5_000, 5_000] + +- name: A spouse below the cap deducts their own taxes + period: 2022 + input: + people: + person1: + age: 45 + real_estate_taxes: 8_000 + person2: + age: 45 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Column A: min(8,000, 5,000); column B: 2,000. + mt_salt_deduction_indiv: [5_000, 2_000] + +- name: The couple's general sales taxes are divided equally between the columns + period: 2021 + input: + people: + person1: + age: 45 + real_estate_taxes: 4_800 + person2: + age: 45 + tax_units: + tax_unit: + members: [person1, person2] + state_sales_tax: 1_000 + local_sales_tax: 0 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5a: 1,000 / 2 = 500 in each column. + # Column A: min(4,800 + 500, 5,000) = 5,000; column B: 500. + mt_salt_deduction_indiv: [5_000, 500] + +- name: A dependent has no column + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 3_000 + person2: + age: 45 + real_estate_taxes: 1_000 + person3: + age: 20 + is_tax_unit_dependent: true + real_estate_taxes: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_salt_deduction_indiv: [3_000, 1_000, 0] + +- name: A single filer's one column takes the single cap + period: 2023 + input: + filing_status: SINGLE + real_estate_taxes: 12_000 + state_code: MT + output: + # min(12,000, 10,000) + mt_salt_deduction_indiv: 10_000 + +- name: A single filer below the single cap deducts all taxes + period: 2022 + input: + filing_status: SINGLE + real_estate_taxes: 8_000 + state_code: MT + output: + mt_salt_deduction_indiv: 8_000 + +- name: A married person filing on a separate form takes the separate cap + period: 2021 + input: + filing_status: SEPARATE + real_estate_taxes: 6_000 + state_code: MT + output: + # min(6,000, 5,000) + mt_salt_deduction_indiv: 5_000 + +- name: No separate columns from 2024 + period: 2024 + input: + people: + person1: + age: 45 + real_estate_taxes: 8_000 + person2: + age: 45 + real_estate_taxes: 8_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # SB 399 ended filing separately on the same form after 2023. + mt_salt_deduction_indiv: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py new file mode 100644 index 00000000000..7b09c4f3bbf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py @@ -0,0 +1,287 @@ +"""Invariants for Montana's state and local tax deduction, 2021-2023. + +Form 2, Itemized Deductions Schedule, line 5 adds lines 5a (general sales +taxes) through 5d and caps the total at $10,000, or $5,000 if married filing +separately. A joint return has one column, so its cap applies once to the +couple's combined taxes (mt_salt_deduction, on the head). Spouses filing +separately on the same form (status 2a) each have their own column, capped at +$5,000 (mt_salt_deduction_indiv). From 2024 the schedule is gone and this +change leaves the earlier per-person amounts as they were. + +The YAML files hold the worked cases. This file checks what they cannot: many +mixed tax units (single, head of household, separate, joint, joint with +dependents, some with sales taxes) in one vectorized simulation, and how +results move when inputs change. For every tax unit: + +1. Differential: both variables equal an independent numpy calculation of + line 5, with the caps taken from the forms rather than from parameters. +2. Bounds and order: each value lies between zero and its cap, and a couple's + two separate columns never deduct more than their joint return. +3. Monotone: raising any member's real estate taxes never lowers the joint + return's or any column's deduction. +4. Head swap: swapping which spouse is the head moves each column with its + spouse and leaves the joint return's deduction unchanged. +5. From 2024 the per-person amounts equal min(real estate taxes, federal cap) + for the tax unit's filing status, as before this change. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Form 2 (2021-2023), Itemized Deductions Schedule, line 5. +FORM_CAP = {"SINGLE": 10_000, "HEAD_OF_HOUSEHOLD": 10_000, "JOINT": 10_000} +FORM_CAP["SEPARATE"] = 5_000 +SCHEDULE_YEARS = [2021, 2022, 2023] +# 26 USC 164(b)(6) and (7)(A): $10,000 ($5,000 separate) through 2024, and +# $40,400 ($20,200 separate) for 2026. +FEDERAL_CAP = { + 2024: {"SINGLE": 10_000, "HEAD_OF_HOUSEHOLD": 10_000, "JOINT": 10_000}, + 2026: {"SINGLE": 40_400, "HEAD_OF_HOUSEHOLD": 40_400, "JOINT": 40_400}, +} +FEDERAL_CAP[2024]["SEPARATE"] = 5_000 +FEDERAL_CAP[2026]["SEPARATE"] = 20_200 +KINDS = ["single", "hoh", "separate", "joint", "joint_dependents"] +STATUS = { + "single": "SINGLE", + "hoh": "HEAD_OF_HOUSEHOLD", + "separate": "SEPARATE", + "joint": "JOINT", + "joint_dependents": "JOINT", +} + +# Mostly amounts near the caps, plus zeros and large amounts. +taxes = st.one_of( + st.just(0.0), + st.integers(1, 6_000).map(float), + st.integers(6_000, 30_000).map(float), +) +sales = st.one_of(st.just(0.0), st.integers(1, 3_000).map(float)) +increases = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) + + +@st.composite +def people(draw): + return {"real_estate_taxes": draw(taxes), "increase": draw(increases)} + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(KINDS)) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "kind": kind, + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + "state_sales_tax": draw(sales), + "local_sales_tax": draw(sales), + } + + +SEED = 20261007 + + +def _seeded_units(n=150): + rng = np.random.default_rng(SEED) + + def person(): + band = rng.choice(3) + return { + "real_estate_taxes": float( + [0, rng.integers(1, 6_001), rng.integers(6_000, 30_001)][band] + ), + "increase": float(rng.integers(0, 5_001)) if rng.random() < 0.7 else 0.0, + } + + units = [] + for _ in range(n): + kind = str(rng.choice(KINDS)) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "kind": kind, + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + "state_sales_tax": float(rng.integers(0, 3_001)) + if rng.random() < 0.3 + else 0.0, + "local_sales_tax": float(rng.integers(0, 601)) + if rng.random() < 0.3 + else 0.0, + } + ) + return units + + +def _situation(units, year, *, raised=False, swapped=False): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role): + people[name] = { + "age": 20 if role == "dependent" else 45, + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "real_estate_taxes": values["real_estate_taxes"] + + raised * values["increase"], + } + + for i, unit in enumerate(units): + first, second = f"first_{i}", f"second_{i}" + couple = unit["spouse"] is not None + # With swapped, the second spouse is the head. + add(first, unit["head"], "spouse" if couple and swapped else "head") + members, marital = [first], [first] + if couple: + add(second, unit["spouse"], "head" if swapped else "spouse") + members.append(second) + marital.append(second) + groups["marital_units"][f"couple_{i}"] = {"members": marital} + for j, dependent in enumerate(unit["dependents"]): + name = f"dependent_{i}_{j}" + add(name, dependent, "dependent") + members.append(name) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "filing_status": STATUS[unit["kind"]], + "state_sales_tax": unit["state_sales_tax"], + "local_sales_tax": unit["local_sales_tax"], + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": "MT", + } + situation = {"people": people, **groups} + for entities in situation.values(): + for values in entities.values(): + for key, value in values.items(): + if key != "members": + values[key] = {year: value} + return situation + + +def _run(units, year, **kwargs): + sim = Simulation(situation=_situation(units, year, **kwargs)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "mt_salt_deduction", + "mt_salt_deduction_indiv", + "real_estate_taxes", + ] + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + out["head"] = np.asarray(sim.calculate("is_tax_unit_head", year)) + out["filer"] = np.asarray(sim.calculate("is_tax_unit_head_or_spouse", year)) + return out + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values) + + +def _check_schedule_year(units, year): + base = _run(units, year) + unit, head, filer = base["unit"], base["head"], base["filer"] + status = np.array([STATUS[u["kind"]] for u in units]) + married = status == "JOINT" + sales = np.array([u["state_sales_tax"] + u["local_sales_tax"] for u in units]) + joint_cap = np.array([FORM_CAP[s] for s in status]) + column_cap = np.where(married, FORM_CAP["SEPARATE"], joint_cap) + + # 1. Differential against numpy. + total = _unit_sum(base, base["real_estate_taxes"]) + sales + np.testing.assert_allclose( + base["mt_salt_deduction"], + head * np.minimum(total, joint_cap)[unit], + atol=TOLERANCE, + ) + share = np.where(married, 0.5, 1.0)[unit] + own = base["real_estate_taxes"] + share * sales[unit] + np.testing.assert_allclose( + base["mt_salt_deduction_indiv"], + filer * np.minimum(own, column_cap[unit]), + atol=TOLERANCE, + ) + + # 2. Bounds and order. + joint = _unit_sum(base, base["mt_salt_deduction"]) + columns = _unit_sum(base, base["mt_salt_deduction_indiv"]) + assert (base["mt_salt_deduction"] >= 0).all() + assert (base["mt_salt_deduction_indiv"] >= 0).all() + assert (joint <= joint_cap + TOLERANCE).all() + assert (base["mt_salt_deduction_indiv"] <= column_cap[unit] + TOLERANCE).all() + assert (columns[married] <= joint[married] + TOLERANCE).all() + + # 3. Monotone in real estate taxes. + raised = _run(units, year, raised=True) + for name in ["mt_salt_deduction", "mt_salt_deduction_indiv"]: + assert (raised[name] >= base[name] - TOLERANCE).all(), name + + # 4. Head swap. + swapped = _run(units, year, swapped=True) + np.testing.assert_allclose( + _unit_sum(swapped, swapped["mt_salt_deduction"]), joint, atol=TOLERANCE + ) + np.testing.assert_allclose( + swapped["mt_salt_deduction_indiv"], + base["mt_salt_deduction_indiv"], + atol=TOLERANCE, + ) + + +def _check_unchanged_year(units, year): + base = _run(units, year) + status = np.array([STATUS[u["kind"]] for u in units]) + cap = np.array([FEDERAL_CAP[year][s] for s in status])[base["unit"]] + np.testing.assert_allclose( + base["mt_salt_deduction"], + np.minimum(base["real_estate_taxes"], cap), + atol=TOLERANCE, + ) + # Spouses no longer file separately on the same form. + assert (base["mt_salt_deduction_indiv"] == 0).all() + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(SCHEDULE_YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_mt_salt_deduction_schedule_invariants(year, units): + _check_schedule_year(units, year) + + +@settings(**SETTINGS) +@given( + st.sampled_from(sorted(FEDERAL_CAP)), st.lists(tax_units(), min_size=5, max_size=30) +) +def test_mt_salt_deduction_unchanged_from_2024(year, units): + _check_unchanged_year(units, year) + + +@pytest.mark.parametrize("year", SCHEDULE_YEARS) +def test_seeded_population_schedule_years(year): + _check_schedule_year(_seeded_units(), year) + + +@pytest.mark.parametrize("year", sorted(FEDERAL_CAP)) +def test_seeded_population_unchanged_from_2024(year): + _check_unchanged_year(_seeded_units(), year) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py index 5d1cb0c5153..2757859ca05 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_indiv.py @@ -30,7 +30,7 @@ def formula(person, period, parameters): [ "mt_misc_deductions", "mt_medical_expense_deduction_indiv", - "mt_salt_deduction", + "mt_salt_deduction_indiv", "mt_federal_income_tax_deduction_for_federal_itemization_indiv", ], ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py index 7b31319e265..ba313b8e88a 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_indiv.py @@ -32,7 +32,7 @@ def formula(person, period, parameters): [ "mt_misc_deductions", "mt_medical_expense_deduction_indiv", - "mt_salt_deduction", + "mt_salt_deduction_indiv", "mt_federal_income_tax_deduction_indiv", ], ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py index e5d5221dbf0..58ef9998cc2 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py @@ -7,19 +7,48 @@ class mt_salt_deduction(Variable): label = "Montana state and local tax deduction" unit = USD definition_period = YEAR + documentation = ( + "Itemized Deductions Schedule, line 5, for a return with one column: " + "a single, head of household or joint return, or a married person " + "filing on a separate form. Through 2023 the return's total is " + "assigned to the tax unit head." + ) reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2021_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", - "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # Montana Code Annotated MT Code ยง 15-30-2131 (2022) (1)(a) + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2021_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=34", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=37", + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + # Former MCA 15-30-2131(1)(a)(ii) (2021): items under IRC 161, except + # state income tax paid. The cap is 26 USC 164(b)(6). ) defined_for = StateCode.MT def formula(person, period, parameters): - # Not included the SALT tax, only real estate taxes - p = parameters(period).gov.irs.deductions + p = parameters(period).gov.irs.deductions.itemized.salt_and_real_estate + p_mt = parameters(period).gov.states.mt.tax.income.deductions.itemized filing_status = person.tax_unit("filing_status", period) + cap = p.cap[filing_status] + if p_mt.state_specific_deduction_applies: + # Line 5 adds lines 5a through 5d for the whole return and caps + # the total once: "not more than $10,000 if your status is + # single, head of household or married filing jointly; or $5,000 + # if you are married filing separately". + # 5a: general state and local sales taxes. + # 5b: local income taxes. No Montana locality levies one, and + # local_income_tax holds only out-of-state local taxes. + # 5c: real estate taxes. + # 5d: value-based personal property taxes, not modeled. + taxes = add( + person.tax_unit, + period, + ["real_estate_taxes", "state_sales_tax", "local_sales_tax"], + ) + is_head = person("is_tax_unit_head", period) + return is_head * min_(taxes, cap) + # From 2024 the schedule is discontinued. This keeps the earlier + # per-person amounts until the 2024 Form 2 line 2 is modeled. real_estate_tax = person("real_estate_taxes", period) - return min_( - real_estate_tax, - p.itemized.salt_and_real_estate.cap[filing_status], - ) + return min_(real_estate_tax, cap) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py new file mode 100644 index 00000000000..4a796339a49 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py @@ -0,0 +1,47 @@ +from policyengine_us.model_api import * + + +class mt_salt_deduction_indiv(Variable): + value_type = float + entity = Person + label = "Montana state and local tax deduction when married couples are filing separately" + unit = USD + definition_period = YEAR + documentation = ( + "Itemized Deductions Schedule, line 5, in each spouse's column when " + "a married couple files separately on the same form (filing status " + "2a). Each column is a separate return, capped at the married filing " + "separately amount." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2021_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=12", + # 2023 instructions, "How to allocate income and deductions when + # filing separately": "each column constituting its own return". + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=37", + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + # Former MCA 15-30-2131(1)(a)(ii) (2021). The cap is 26 USC 164(b)(6). + ) + defined_for = "mt_married_filing_separately_on_same_return_eligible" + + def formula(person, period, parameters): + p = parameters(period).gov.irs.deductions.itemized.salt_and_real_estate + p_mt = parameters(period).gov.states.mt.tax.income.deductions.itemized + filing_status = person.tax_unit("filing_status", period) + # A married couple's columns are each "married filing separately": + # "not more than ... $5,000 if you are married filing separately". + # Other filers have one column at their own status's cap. + married = filing_status == filing_status.possible_values.JOINT + cap = where(married, p.cap.SEPARATE, p.cap[filing_status]) + # Line 5c: each spouse's own real estate taxes. + real_estate_tax = person("real_estate_taxes", period) + # Line 5a: PolicyEngine estimates sales taxes for the tax unit only. + # Jointly paid expenses "can be allocated to either spouse in any + # proportional amount", so a couple divides them equally. + sales_tax = add(person.tax_unit, period, ["state_sales_tax", "local_sales_tax"]) + sales_tax_share = where(married, p_mt.spouse_allocation_rate, 1) + taxes = real_estate_tax + sales_tax_share * sales_tax + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + return head_or_spouse * min_(taxes, cap) From 9d184261a946167de92ef08361e4e404dfcb2382 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 17:11:38 -0400 Subject: [PATCH 2/3] Include local income taxes (line 5b) in Montana's 2021-2023 SALT deduction Review r1 found that local_income_tax is not always zero for a Montana household: PolicyEngine levies the Kansas City and St. Louis earnings taxes and the Philadelphia wage tax on earnings in those cities without a residence test. Line 5 adds "any local income taxes that you paid". - mt_salt_deduction: the joint return's line 5 adds local_income_tax. - mt_salt_deduction_indiv: each column takes the tax unit's local income tax in proportion to the person's own earnings taxed by those cities ("Deductions that are attributable to only one spouse must be claimed by that spouse"); without such earnings it is divided like the sales taxes. - YAML: Kansas City cases for a single return, a joint return at the cap, and two separate-column cases. The Hypothesis file adds Kansas City earnings to its draws and reference calculation. Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-salt-cap-joint-return.fixed.md | 2 +- .../itemized/mt_salt_deduction.yaml | 33 +++++++++++++ .../itemized/mt_salt_deduction_indiv.yaml | 49 +++++++++++++++++++ .../test_mt_salt_deduction_invariants.py | 29 ++++++++--- .../itemized/salt/mt_salt_deduction.py | 11 +++-- .../itemized/salt/mt_salt_deduction_indiv.py | 30 +++++++++++- 6 files changed, 143 insertions(+), 11 deletions(-) diff --git a/changelog.d/fix-mt-salt-cap-joint-return.fixed.md b/changelog.d/fix-mt-salt-cap-joint-return.fixed.md index 925a0a126f2..512c8a641c6 100644 --- a/changelog.d/fix-mt-salt-cap-joint-return.fixed.md +++ b/changelog.d/fix-mt-salt-cap-joint-return.fixed.md @@ -1 +1 @@ -Cap Montana's 2021โ€“2023 state and local tax deduction (Itemized Deductions Schedule, line 5) once for a joint return and at $5,000 in each spouse's column when filing separately on the same form, and include general sales taxes (line 5a). +Cap Montana's 2021โ€“2023 state and local tax deduction (Itemized Deductions Schedule, line 5) once for a joint return and at $5,000 in each spouse's column when filing separately on the same form, and include general sales taxes (line 5a) and local income taxes (line 5b). diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml index f06ff3db79d..0860882f903 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml @@ -127,3 +127,36 @@ output: # Line 5a: 1,800; line 5c: 2,000. Line 5: 3,800, under the 10,000 cap. mt_salt_deduction: 3_800 + +- name: Single return adds another city's earnings tax + period: 2023 + input: + filing_status: SINGLE + real_estate_taxes: 2_000 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + state_code: MT + output: + # Line 5b: Kansas City earnings tax, 1% x 100,000 = 1,000; line 5c: + # 2,000. Line 5: 3,000. + mt_salt_deduction: 3_000 + +- name: Joint return adds a spouse's city earnings tax before the cap + period: 2022 + input: + people: + person1: + age: 45 + real_estate_taxes: 9_500 + person2: + age: 45 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5b: 1,000; line 5c: 9,500. Line 5: min(10,500, 10,000). + mt_salt_deduction: [10_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml index a04cb3d1d85..66c9297ba16 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml @@ -147,3 +147,52 @@ output: # SB 399 ended filing separately on the same form after 2023. mt_salt_deduction_indiv: [0, 0] + +# Line 5b: "Deductions that are attributable to only one spouse must be +# claimed by that spouse" (2023 instructions, PDF p. 13). A city earnings tax +# belongs to the spouse who earned the taxed income. + +- name: A city earnings tax goes in the column of the spouse who earned it + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 3_000 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + person2: + age: 45 + real_estate_taxes: 3_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Column A: 3,000 + 1% x 100,000 = 4,000; column B: 3,000. + mt_salt_deduction_indiv: [4_000, 3_000] + +- name: Both spouses' city earnings taxes, one column at the cap + period: 2022 + input: + people: + person1: + age: 45 + real_estate_taxes: 4_500 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + person2: + age: 45 + mo_kansas_city_earnings_tax_taxable_earnings: 50_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Kansas City tax: 1,000 on column A's earnings and 500 on column B's. + # Column A: min(4,500 + 1,000, 5,000) = 5,000; column B: 500. + mt_salt_deduction_indiv: [5_000, 500] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py index 7b09c4f3bbf..34d8bed22d8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py @@ -1,7 +1,7 @@ """Invariants for Montana's state and local tax deduction, 2021-2023. Form 2, Itemized Deductions Schedule, line 5 adds lines 5a (general sales -taxes) through 5d and caps the total at $10,000, or $5,000 if married filing +taxes), 5b (local income taxes) through 5d and caps the total at $10,000, or $5,000 if married filing separately. A joint return has one column, so its cap applies once to the couple's combined taxes (mt_salt_deduction, on the head). Spouses filing separately on the same form (status 2a) each have their own column, capped at @@ -10,7 +10,8 @@ The YAML files hold the worked cases. This file checks what they cannot: many mixed tax units (single, head of household, separate, joint, joint with -dependents, some with sales taxes) in one vectorized simulation, and how +dependents, some with sales taxes or Kansas City earnings taxes) in one +vectorized simulation, and how results move when inputs change. For every tax unit: 1. Differential: both variables equal an independent numpy calculation of @@ -38,6 +39,8 @@ FORM_CAP = {"SINGLE": 10_000, "HEAD_OF_HOUSEHOLD": 10_000, "JOINT": 10_000} FORM_CAP["SEPARATE"] = 5_000 SCHEDULE_YEARS = [2021, 2022, 2023] +# Kansas City earnings tax rate, a local income tax with no residence test. +KANSAS_CITY_RATE = 0.01 # 26 USC 164(b)(6) and (7)(A): $10,000 ($5,000 separate) through 2024, and # $40,400 ($20,200 separate) for 2026. FEDERAL_CAP = { @@ -63,11 +66,16 @@ ) sales = st.one_of(st.just(0.0), st.integers(1, 3_000).map(float)) increases = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) +city_earnings = st.one_of(st.just(0.0), st.integers(1, 300_000).map(float)) @st.composite def people(draw): - return {"real_estate_taxes": draw(taxes), "increase": draw(increases)} + return { + "real_estate_taxes": draw(taxes), + "increase": draw(increases), + "kansas_city_earnings": draw(city_earnings), + } @st.composite @@ -97,6 +105,9 @@ def person(): [0, rng.integers(1, 6_001), rng.integers(6_000, 30_001)][band] ), "increase": float(rng.integers(0, 5_001)) if rng.random() < 0.7 else 0.0, + "kansas_city_earnings": float(rng.integers(1, 300_001)) + if rng.random() < 0.2 + else 0.0, } units = [] @@ -133,6 +144,9 @@ def add(name, values, role): "is_tax_unit_dependent": role == "dependent", "real_estate_taxes": values["real_estate_taxes"] + raised * values["increase"], + "mo_kansas_city_earnings_tax_taxable_earnings": values[ + "kansas_city_earnings" + ], } for i, unit in enumerate(units): @@ -178,6 +192,7 @@ def _run(units, year, **kwargs): "mt_salt_deduction", "mt_salt_deduction_indiv", "real_estate_taxes", + "mo_kansas_city_earnings_tax_taxable_earnings", ] } out["unit"] = sim.populations["tax_unit"].members_entity_id @@ -199,15 +214,17 @@ def _check_schedule_year(units, year): joint_cap = np.array([FORM_CAP[s] for s in status]) column_cap = np.where(married, FORM_CAP["SEPARATE"], joint_cap) - # 1. Differential against numpy. - total = _unit_sum(base, base["real_estate_taxes"]) + sales + # 1. Differential against numpy. Each person owes Kansas City tax on + # their own earnings there; a column takes its own spouse's tax. + city_tax = KANSAS_CITY_RATE * base["mo_kansas_city_earnings_tax_taxable_earnings"] + total = _unit_sum(base, base["real_estate_taxes"] + city_tax) + sales np.testing.assert_allclose( base["mt_salt_deduction"], head * np.minimum(total, joint_cap)[unit], atol=TOLERANCE, ) share = np.where(married, 0.5, 1.0)[unit] - own = base["real_estate_taxes"] + share * sales[unit] + own = base["real_estate_taxes"] + share * sales[unit] + city_tax np.testing.assert_allclose( base["mt_salt_deduction_indiv"], filer * np.minimum(own, column_cap[unit]), diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py index 58ef9998cc2..00c82008e1c 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction.py @@ -37,14 +37,19 @@ def formula(person, period, parameters): # single, head of household or married filing jointly; or $5,000 # if you are married filing separately". # 5a: general state and local sales taxes. - # 5b: local income taxes. No Montana locality levies one, and - # local_income_tax holds only out-of-state local taxes. + # 5b: local income taxes. No Montana locality levies one, but + # a resident can owe another city's earnings or wage tax. # 5c: real estate taxes. # 5d: value-based personal property taxes, not modeled. taxes = add( person.tax_unit, period, - ["real_estate_taxes", "state_sales_tax", "local_sales_tax"], + [ + "real_estate_taxes", + "state_sales_tax", + "local_sales_tax", + "local_income_tax", + ], ) is_head = person("is_tax_unit_head", period) return is_head * min_(taxes, cap) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py index 4a796339a49..4ae7cd6100a 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py @@ -42,6 +42,34 @@ def formula(person, period, parameters): # proportional amount", so a couple divides them equally. sales_tax = add(person.tax_unit, period, ["state_sales_tax", "local_sales_tax"]) sales_tax_share = where(married, p_mt.spouse_allocation_rate, 1) - taxes = real_estate_tax + sales_tax_share * sales_tax + # Line 5b: "Deductions that are attributable to only one spouse must + # be claimed by that spouse." PolicyEngine computes local income + # taxes for the tax unit. The ones it levies on a Montana household + # are city taxes on earnings there (Kansas City, St. Louis, + # Philadelphia), so each person takes a share in proportion to their + # own earnings taxed by those cities. Without such earnings the tax + # is divided like the sales taxes. + local_base = add( + person, + period, + [ + "mo_kansas_city_earnings_tax_taxable_earnings", + "mo_st_louis_earnings_tax_taxable_earnings", + "pa_philadelphia_wage_tax_taxable_wages", + ], + ) + unit_local_base = person.tax_unit.sum(local_base) + has_local_base = unit_local_base > 0 + local_share = where( + has_local_base, + local_base / where(has_local_base, unit_local_base, 1), + sales_tax_share, + ) + local_income_tax = person.tax_unit("local_income_tax", period) + taxes = ( + real_estate_tax + + sales_tax_share * sales_tax + + local_share * local_income_tax + ) head_or_spouse = person("is_tax_unit_head_or_spouse", period) return head_or_spouse * min_(taxes, cap) From 0a4846c7b1ab8d9459e13c80cd877ef97c05538f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 10:42:41 -0400 Subject: [PATCH 3/3] Give each spouse's Montana column their own city tax (line 5b) Review r2: dividing the tax unit's local income tax by each spouse's share of taxable city earnings is exact only when everyone pays the same rate with no credit. PolicyEngine sets Philadelphia's rate per person and subtracts St. Louis credits per person. - New Person variables mo_kansas_city_earnings_tax_person, mo_st_louis_earnings_tax_person and pa_philadelphia_wage_tax_person hold each person's own tax, with the existing formulas. The tax-unit variables now add them up, so their values do not change. - mt_salt_deduction_indiv: each column takes its own spouse's city tax. Any other local income tax of the tax unit (none for a Montana household) is divided like the sales taxes. - YAML: first tests for the three city taxes; Montana cases for a Philadelphia reduced rate, a St. Louis credit, two cities with one column at the cap, and a joint return with two cities. - Hypothesis: St. Louis earnings and per-person credits in the draws. Co-Authored-By: Claude Opus 5.5 --- .../income/mo_kansas_city_earnings_tax.yaml | 21 ++++++ .../tax/income/mo_st_louis_earnings_tax.yaml | 24 +++++++ .../tax/income/pa_philadelphia_wage_tax.yaml | 37 ++++++++++ .../itemized/mt_salt_deduction.yaml | 23 +++++++ .../itemized/mt_salt_deduction_indiv.yaml | 68 +++++++++++++++++++ .../test_mt_salt_deduction_invariants.py | 49 +++++++++---- .../tax/income/mo_kansas_city_earnings_tax.py | 9 +-- .../mo_kansas_city_earnings_tax_person.py | 18 +++++ .../tax/income/mo_st_louis_earnings_tax.py | 9 +-- .../income/mo_st_louis_earnings_tax_person.py | 23 +++++++ .../tax/income/pa_philadelphia_wage_tax.py | 16 +---- .../income/pa_philadelphia_wage_tax_person.py | 31 +++++++++ .../itemized/salt/mt_salt_deduction_indiv.py | 31 ++++----- 13 files changed, 296 insertions(+), 63 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.yaml create mode 100644 policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax_person.py create mode 100644 policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax_person.py create mode 100644 policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax_person.py diff --git a/policyengine_us/tests/policy/baseline/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.yaml b/policyengine_us/tests/policy/baseline/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.yaml new file mode 100644 index 00000000000..1096df63047 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.yaml @@ -0,0 +1,21 @@ +- name: Each person owes 1% of their own Kansas City taxable earnings + period: 2024 + input: + people: + person1: + age: 45 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + person2: + age: 45 + mo_kansas_city_earnings_tax_taxable_earnings: 50_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MO + output: + # 1% x 100,000 and 1% x 50,000. + mo_kansas_city_earnings_tax_person: [1_000, 500] + mo_kansas_city_earnings_tax: 1_500 diff --git a/policyengine_us/tests/policy/baseline/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.yaml b/policyengine_us/tests/policy/baseline/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.yaml new file mode 100644 index 00000000000..dc90c9bc2c9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.yaml @@ -0,0 +1,24 @@ +- name: A person's credit reduces only their own St. Louis earnings tax + period: 2024 + input: + people: + person1: + age: 45 + mo_st_louis_earnings_tax_taxable_earnings: 100_000 + mo_st_louis_earnings_tax_credit: 1_200 + person2: + age: 45 + mo_st_louis_earnings_tax_taxable_earnings: 100_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MO + output: + # 1% x 100,000 = 1,000 each. Person 1's 1,200 credit takes their tax to + # zero and does not reduce person 2's. + mo_st_louis_earnings_tax_person: [0, 1_000] + mo_st_louis_earnings_tax: 1_000 + mo_st_louis_earnings_tax_before_credit: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.yaml b/policyengine_us/tests/policy/baseline/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.yaml new file mode 100644 index 00000000000..f7b94f9bcbb --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.yaml @@ -0,0 +1,37 @@ +- name: Each person's Philadelphia wage tax uses their own rate + period: 2024 + input: + people: + person1: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + pa_philadelphia_wage_tax_resident: true + person2: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + pa_philadelphia_wage_tax_resident: false + person3: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 40_000 + pa_philadelphia_wage_tax_resident: true + pa_philadelphia_wage_tax_reduced_rate_eligible: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: PA + output: + # Rates from July 2023: resident 3.75%, nonresident 3.44%, reduced 1.5%. + pa_philadelphia_wage_tax_person: [3_750, 3_440, 600] + pa_philadelphia_wage_tax: 7_790 + +- name: No Philadelphia wages, no tax + period: 2024 + input: + pa_philadelphia_wage_tax_taxable_wages: 0 + state_code: PA + output: + pa_philadelphia_wage_tax_person: 0 + pa_philadelphia_wage_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml index 0860882f903..51d97882b53 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction.yaml @@ -160,3 +160,26 @@ output: # Line 5b: 1,000; line 5c: 9,500. Line 5: min(10,500, 10,000). mt_salt_deduction: [10_000, 0] + +- name: Joint return adds both spouses' city taxes before the cap + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 4_000 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + person2: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 5b: 1,000 (Kansas City) + 3,440 (Philadelphia nonresident 3.44%); + # line 5c: 4,000. Line 5: 8,440, under the 10,000 cap. + mt_salt_deduction: [8_440, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml index 66c9297ba16..fcb4eb1a0b1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_salt_deduction_indiv.yaml @@ -196,3 +196,71 @@ # Kansas City tax: 1,000 on column A's earnings and 500 on column B's. # Column A: min(4,500 + 1,000, 5,000) = 5,000; column B: 500. mt_salt_deduction_indiv: [5_000, 500] + +- name: Each column takes its own spouse's Philadelphia wage tax at that spouse's rate + period: 2023 + input: + people: + person1: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + person2: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + pa_philadelphia_wage_tax_reduced_rate_eligible: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Nonresident rate 3.44% x 100,000 = 3,440; reduced rate 1.5% x 100,000 + # = 1,500. Splitting the 4,940 by wages would give 2,470 each. + mt_salt_deduction_indiv: [3_440, 1_500] + +- name: A St. Louis credit reduces only its own spouse's column + period: 2023 + input: + people: + person1: + age: 45 + mo_st_louis_earnings_tax_taxable_earnings: 100_000 + mo_st_louis_earnings_tax_credit: 1_000 + person2: + age: 45 + mo_st_louis_earnings_tax_taxable_earnings: 100_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Column A: 1% x 100,000 - 1,000 credit = 0; column B: 1,000. + mt_salt_deduction_indiv: [0, 1_000] + +- name: Two cities' taxes, one column at the cap + period: 2023 + input: + people: + person1: + age: 45 + real_estate_taxes: 4_000 + mo_kansas_city_earnings_tax_taxable_earnings: 100_000 + person2: + age: 45 + pa_philadelphia_wage_tax_taxable_wages: 100_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # Column A: min(4,000 + 1% x 100,000, 5,000) = 5,000. + # Column B: 3.44% x 100,000 = 3,440. + mt_salt_deduction_indiv: [5_000, 3_440] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py index 34d8bed22d8..8a008587b55 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/test_mt_salt_deduction_invariants.py @@ -1,18 +1,19 @@ """Invariants for Montana's state and local tax deduction, 2021-2023. Form 2, Itemized Deductions Schedule, line 5 adds lines 5a (general sales -taxes), 5b (local income taxes) through 5d and caps the total at $10,000, or $5,000 if married filing -separately. A joint return has one column, so its cap applies once to the -couple's combined taxes (mt_salt_deduction, on the head). Spouses filing -separately on the same form (status 2a) each have their own column, capped at -$5,000 (mt_salt_deduction_indiv). From 2024 the schedule is gone and this -change leaves the earlier per-person amounts as they were. +taxes), 5b (local income taxes), 5c (real estate taxes) and 5d, and caps the +total at $10,000, or $5,000 if married filing separately. A joint return has +one column, so its cap applies once to the couple's combined taxes +(mt_salt_deduction, on the head). Spouses filing separately on the same form +(status 2a) each have their own column, capped at $5,000 +(mt_salt_deduction_indiv). From 2024 the schedule is gone and this change +leaves the earlier per-person amounts as they were. The YAML files hold the worked cases. This file checks what they cannot: many mixed tax units (single, head of household, separate, joint, joint with -dependents, some with sales taxes or Kansas City earnings taxes) in one -vectorized simulation, and how -results move when inputs change. For every tax unit: +dependents, some with sales taxes or Kansas City and St. Louis earnings taxes) +in one vectorized simulation, and how results move when inputs change. For +every tax unit: 1. Differential: both variables equal an independent numpy calculation of line 5, with the caps taken from the forms rather than from parameters. @@ -39,8 +40,10 @@ FORM_CAP = {"SINGLE": 10_000, "HEAD_OF_HOUSEHOLD": 10_000, "JOINT": 10_000} FORM_CAP["SEPARATE"] = 5_000 SCHEDULE_YEARS = [2021, 2022, 2023] -# Kansas City earnings tax rate, a local income tax with no residence test. +# Kansas City and St. Louis earnings tax rates: local income taxes with no +# residence test, on each person's own earnings in the city. KANSAS_CITY_RATE = 0.01 +ST_LOUIS_RATE = 0.01 # 26 USC 164(b)(6) and (7)(A): $10,000 ($5,000 separate) through 2024, and # $40,400 ($20,200 separate) for 2026. FEDERAL_CAP = { @@ -67,6 +70,7 @@ sales = st.one_of(st.just(0.0), st.integers(1, 3_000).map(float)) increases = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) city_earnings = st.one_of(st.just(0.0), st.integers(1, 300_000).map(float)) +city_credits = st.one_of(st.just(0.0), st.integers(1, 3_000).map(float)) @st.composite @@ -75,6 +79,8 @@ def people(draw): "real_estate_taxes": draw(taxes), "increase": draw(increases), "kansas_city_earnings": draw(city_earnings), + "st_louis_earnings": draw(city_earnings), + "st_louis_credit": draw(city_credits), } @@ -108,6 +114,12 @@ def person(): "kansas_city_earnings": float(rng.integers(1, 300_001)) if rng.random() < 0.2 else 0.0, + "st_louis_earnings": float(rng.integers(1, 300_001)) + if rng.random() < 0.2 + else 0.0, + "st_louis_credit": float(rng.integers(1, 3_001)) + if rng.random() < 0.3 + else 0.0, } units = [] @@ -147,6 +159,8 @@ def add(name, values, role): "mo_kansas_city_earnings_tax_taxable_earnings": values[ "kansas_city_earnings" ], + "mo_st_louis_earnings_tax_taxable_earnings": values["st_louis_earnings"], + "mo_st_louis_earnings_tax_credit": values["st_louis_credit"], } for i, unit in enumerate(units): @@ -193,6 +207,8 @@ def _run(units, year, **kwargs): "mt_salt_deduction_indiv", "real_estate_taxes", "mo_kansas_city_earnings_tax_taxable_earnings", + "mo_st_louis_earnings_tax_taxable_earnings", + "mo_st_louis_earnings_tax_credit", ] } out["unit"] = sim.populations["tax_unit"].members_entity_id @@ -214,9 +230,16 @@ def _check_schedule_year(units, year): joint_cap = np.array([FORM_CAP[s] for s in status]) column_cap = np.where(married, FORM_CAP["SEPARATE"], joint_cap) - # 1. Differential against numpy. Each person owes Kansas City tax on - # their own earnings there; a column takes its own spouse's tax. - city_tax = KANSAS_CITY_RATE * base["mo_kansas_city_earnings_tax_taxable_earnings"] + # 1. Differential against numpy. Each person owes city tax on their own + # earnings there, less their own credits; a column takes its own + # spouse's tax. + city_tax = KANSAS_CITY_RATE * base[ + "mo_kansas_city_earnings_tax_taxable_earnings" + ] + np.maximum( + 0, + ST_LOUIS_RATE * base["mo_st_louis_earnings_tax_taxable_earnings"] + - base["mo_st_louis_earnings_tax_credit"], + ) total = _unit_sum(base, base["real_estate_taxes"] + city_tax) + sales np.testing.assert_allclose( base["mt_salt_deduction"], diff --git a/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.py b/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.py index 220f8254fd2..a31e9c6523a 100644 --- a/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.py +++ b/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax.py @@ -11,11 +11,4 @@ class mo_kansas_city_earnings_tax(Variable): definition_period = YEAR unit = USD reference = "https://www.kcmo.gov/city-hall/departments/finance/earnings-tax" - - def formula(tax_unit, period, parameters): - person = tax_unit.members - p = parameters(period).gov.local.mo.kansas_city.tax.income - taxable_earnings = person( - "mo_kansas_city_earnings_tax_taxable_earnings", period - ) - return tax_unit.sum(taxable_earnings * p.rate) + adds = ["mo_kansas_city_earnings_tax_person"] diff --git a/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax_person.py b/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax_person.py new file mode 100644 index 00000000000..99ff1759eda --- /dev/null +++ b/policyengine_us/variables/gov/local/mo/kansas_city/tax/income/mo_kansas_city_earnings_tax_person.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class mo_kansas_city_earnings_tax_person(Variable): + value_type = float + entity = Person + label = "Kansas City earnings tax for each person" + documentation = "Kansas City earnings tax on the person's own taxable earnings." + definition_period = YEAR + unit = USD + reference = "https://www.kcmo.gov/city-hall/departments/finance/earnings-tax" + + def formula(person, period, parameters): + p = parameters(period).gov.local.mo.kansas_city.tax.income + taxable_earnings = person( + "mo_kansas_city_earnings_tax_taxable_earnings", period + ) + return taxable_earnings * p.rate diff --git a/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.py b/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.py index 1c31987db47..42bbf173fb7 100644 --- a/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.py +++ b/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax.py @@ -14,11 +14,4 @@ class mo_st_louis_earnings_tax(Variable): "https://www.stlouis-mo.gov/government/departments/collector/" "earnings-tax/file-earnings-tax.cfm" ) - - def formula(tax_unit, period, parameters): - person = tax_unit.members - p = parameters(period).gov.local.mo.st_louis.tax.income - taxable_earnings = person("mo_st_louis_earnings_tax_taxable_earnings", period) - credits = person("mo_st_louis_earnings_tax_credit", period) - person_tax = taxable_earnings * p.rate - return tax_unit.sum(max_(0, person_tax - credits)) + adds = ["mo_st_louis_earnings_tax_person"] diff --git a/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax_person.py b/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax_person.py new file mode 100644 index 00000000000..8a2b5aa7f5d --- /dev/null +++ b/policyengine_us/variables/gov/local/mo/st_louis/tax/income/mo_st_louis_earnings_tax_person.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class mo_st_louis_earnings_tax_person(Variable): + value_type = float + entity = Person + label = "St. Louis earnings tax for each person" + documentation = ( + "St. Louis earnings tax on the person's own taxable earnings, after " + "the person's optional credits supplied as inputs." + ) + definition_period = YEAR + unit = USD + reference = ( + "https://www.stlouis-mo.gov/government/departments/collector/" + "earnings-tax/file-earnings-tax.cfm" + ) + + def formula(person, period, parameters): + p = parameters(period).gov.local.mo.st_louis.tax.income + taxable_earnings = person("mo_st_louis_earnings_tax_taxable_earnings", period) + credits = person("mo_st_louis_earnings_tax_credit", period) + return max_(0, taxable_earnings * p.rate - credits) diff --git a/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.py b/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.py index 62e37e476a6..f74a1154d72 100644 --- a/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.py +++ b/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax.py @@ -15,18 +15,4 @@ class pa_philadelphia_wage_tax(Variable): "https://www.phila.gov/services/payments-assistance-taxes/taxes/" "business-taxes/business-taxes-by-type/wage-tax-employers/" ) - - def formula(tax_unit, period, parameters): - person = tax_unit.members - taxable_wages = person("pa_philadelphia_wage_tax_taxable_wages", period) - if not np.any(taxable_wages): - return 0 - - p = parameters(period).gov.local.pa.philadelphia.tax.income - resident = person("pa_philadelphia_wage_tax_resident", period) - reduced_rate_eligible = person( - "pa_philadelphia_wage_tax_reduced_rate_eligible", period - ) - standard_rate = where(resident, p.resident_rate, p.nonresident_rate) - rate = where(reduced_rate_eligible, p.reduced_rate, standard_rate) - return tax_unit.sum(taxable_wages * rate) + adds = ["pa_philadelphia_wage_tax_person"] diff --git a/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax_person.py b/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax_person.py new file mode 100644 index 00000000000..3b2018fae7c --- /dev/null +++ b/policyengine_us/variables/gov/local/pa/philadelphia/tax/income/pa_philadelphia_wage_tax_person.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class pa_philadelphia_wage_tax_person(Variable): + value_type = float + entity = Person + label = "Philadelphia wage tax for each person" + documentation = ( + "Philadelphia employee wage or earnings tax on the person's own " + "taxable wages, at the rate for the person's resident status." + ) + definition_period = YEAR + unit = USD + reference = ( + "https://www.phila.gov/services/payments-assistance-taxes/taxes/" + "business-taxes/business-taxes-by-type/wage-tax-employers/" + ) + + def formula(person, period, parameters): + taxable_wages = person("pa_philadelphia_wage_tax_taxable_wages", period) + if not np.any(taxable_wages): + return 0 + + p = parameters(period).gov.local.pa.philadelphia.tax.income + resident = person("pa_philadelphia_wage_tax_resident", period) + reduced_rate_eligible = person( + "pa_philadelphia_wage_tax_reduced_rate_eligible", period + ) + standard_rate = where(resident, p.resident_rate, p.nonresident_rate) + rate = where(reduced_rate_eligible, p.reduced_rate, standard_rate) + return taxable_wages * rate diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py index 4ae7cd6100a..3054cfc2aae 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/salt/mt_salt_deduction_indiv.py @@ -43,33 +43,26 @@ def formula(person, period, parameters): sales_tax = add(person.tax_unit, period, ["state_sales_tax", "local_sales_tax"]) sales_tax_share = where(married, p_mt.spouse_allocation_rate, 1) # Line 5b: "Deductions that are attributable to only one spouse must - # be claimed by that spouse." PolicyEngine computes local income - # taxes for the tax unit. The ones it levies on a Montana household - # are city taxes on earnings there (Kansas City, St. Louis, - # Philadelphia), so each person takes a share in proportion to their - # own earnings taxed by those cities. Without such earnings the tax - # is divided like the sales taxes. - local_base = add( + # be claimed by that spouse." The local income taxes PolicyEngine + # levies on a Montana household are city taxes on each person's own + # earnings there (Kansas City, St. Louis, Philadelphia), so each + # column takes that person's own tax. Any other local income tax of + # the tax unit is divided like the sales taxes. + own_local_tax = add( person, period, [ - "mo_kansas_city_earnings_tax_taxable_earnings", - "mo_st_louis_earnings_tax_taxable_earnings", - "pa_philadelphia_wage_tax_taxable_wages", + "mo_kansas_city_earnings_tax_person", + "mo_st_louis_earnings_tax_person", + "pa_philadelphia_wage_tax_person", ], ) - unit_local_base = person.tax_unit.sum(local_base) - has_local_base = unit_local_base > 0 - local_share = where( - has_local_base, - local_base / where(has_local_base, unit_local_base, 1), - sales_tax_share, - ) local_income_tax = person.tax_unit("local_income_tax", period) + other_local_tax = max_(0, local_income_tax - person.tax_unit.sum(own_local_tax)) taxes = ( real_estate_tax - + sales_tax_share * sales_tax - + local_share * local_income_tax + + sales_tax_share * (sales_tax + other_local_tax) + + own_local_tax ) head_or_spouse = person("is_tax_unit_head_or_spouse", period) return head_or_spouse * min_(taxes, cap)