diff --git a/changelog.d/fix-mt-ehrc-household-member-income.fixed.md b/changelog.d/fix-mt-ehrc-household-member-income.fixed.md new file mode 100644 index 00000000000..1406d26b72a --- /dev/null +++ b/changelog.d/fix-mt-ehrc-household-member-income.fixed.md @@ -0,0 +1 @@ +Apply the full Montana elderly homeowner/renter credit multiplier when permitted adjustments make household income negative. diff --git a/changelog.d/fix-mt-ehrc-one-claimant-per-household.fixed.md b/changelog.d/fix-mt-ehrc-one-claimant-per-household.fixed.md new file mode 100644 index 00000000000..2f6c0989d0b --- /dev/null +++ b/changelog.d/fix-mt-ehrc-one-claimant-per-household.fixed.md @@ -0,0 +1 @@ +Limit the Montana elderly homeowner/renter credit to one claimant per household (MCA 15-30-2341(1)): when several tax units in a household qualify, only the one with the largest credit is paid. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml index 472a0218dd0..77815f66850 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.yaml @@ -11,6 +11,10 @@ tax_units: tax_unit: members: [person1] + households: + household: + members: [person1] + state_code: MT output: mt_elderly_homeowner_or_renter_credit: 0 @@ -33,6 +37,10 @@ tax_units: tax_unit: members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT output: # min_((propertytax + rent*rate - (grossincome-standard)*reduction_rate), 1150) * percentage(grossincome) # 1500+ 9000*2*0.15 - (34000-12600)*0.05 = 3130 # 1150*100% @@ -57,6 +65,10 @@ tax_units: tax_unit: members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT output: # min_((propertytax + rent*rate - (grossincome-standard)*reduction_rate), 1000) * percentage(grossincome) # 1500+ 9000*2*0.15 - (34000-6300)*0.05 = 2815 # 1000*100% @@ -87,6 +99,10 @@ tax_units: tax_unit: members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT output: # min_((propertytax + rent*rate - (grossincome-standard)*reduction_rate), 1150) * percentage(grossincome) # 1000+ 9000*0.15 - (43000-12600)*0.05 = 830 # 830*10% = 83 @@ -111,6 +127,10 @@ tax_units: tax_unit: members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT output: # Each person's gross household income is under the $35,000 threshold # (multiplier 1.0 per-person), but the tax-unit sum is $44,000 which @@ -130,6 +150,10 @@ tax_units: tax_unit: members: [person1] + households: + household: + members: [person1] + state_code: MT output: mt_elderly_homeowner_or_renter_credit: 0 @@ -408,6 +432,201 @@ mt_elderly_homeowner_or_renter_credit_net_household_income: [870, 0] mt_elderly_homeowner_or_renter_credit: [1_130, 0] +- name: 2024 Schedule 2EC, two eligible siblings filing separately in one household; only the larger claim is paid + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + taxable_pension_income: 10_000 + rent: 4_800 + person2: + age: 70 + taxable_pension_income: 15_000 + rent: 7_200 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Both siblings are 62 or older and file their own returns. Line 18 + # gross household income = 10_000 + 15_000 = 25_000 for each; line 20 = + # 25_000 - 12_600 = 12_400; line 21 = 0.05; line 22 = 620. + # person1: line 25 = 4_800 x 0.15 = 720; line 27 = 720 - 620 = 100. + # person2: line 25 = 7_200 x 0.15 = 1_080; line 27 = 1_080 - 620 = 460. + # Line 29 = 100% (under 35_000). "Only one claimant per household in a + # claim period ... is entitled to relief" (§ 15-30-2341(1)); the + # household claims through person2, whose credit is larger. Before the + # limit both were paid, 560 in total. + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: [100, 460] + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true] + mt_elderly_homeowner_or_renter_credit: [0, 460] + mt_refundable_credits: [0, 460] + taxsim_state_property_tax_credit: [0, 460] + # 2024 Form 2, PDF p. 1: federal standard deduction 16,550 plus + # old-age subtraction 5,500 exceeds either pension. Tax before + # refundable credits is zero, so only person2 receives a refund. + mt_income_tax: [0, -460] + +- name: 2024 Schedule 2EC, one eligible tax unit and one younger household member who files separately + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 10_000 + rent: 4_800 + person2: + age: 58 + taxable_pension_income: 15_000 + rent: 7_200 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Same household as above, but person2 is under 62 and cannot be a + # claimant (§ 15-30-2338(1)(a)), even though their rent is larger. + # Line 22 = 620; person1's line 27 = 720 - 620 = 100. + mt_elderly_homeowner_or_renter_credit_selected_claimant: [true, false] + mt_elderly_homeowner_or_renter_credit: [100, 0] + +- name: 2025 Schedule 2EC, a joint couple who both qualify make one claim + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 15_000 + real_estate_taxes: 2_000 + person2: + age: 66 + taxable_pension_income: 10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # "If filing jointly and both spouses qualify for the credit, enter the + # spouse listed as the taxpayer on page 1 as the claimant" (2025 + # instructions). Line 18 = 25_000; line 22 = 12_400 x 0.05 = 620; + # line 27 = 2_000 - 620 = 1_380; line 28 = 1_150 cap; line 30 = 1_150, + # held by the tax unit head. + mt_elderly_homeowner_or_renter_credit_selected_claimant: true + mt_elderly_homeowner_or_renter_credit: [1_150, 0] + +- name: 2024 Schedule 2EC, two eligible tax units with equal credits; exactly one claims + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 68 + taxable_pension_income: 12_500 + rent: 6_000 + person2: + age: 72 + taxable_pension_income: 12_500 + rent: 6_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_name: MT + output: + # Line 18 = 25_000; line 22 = 620; each line 27 = 900 - 620 = 280. The + # household gets one 280 credit; the tie goes to the first tax unit in + # the household's member order. + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: [280, 280] + mt_elderly_homeowner_or_renter_credit_selected_claimant: [true, false] + mt_elderly_homeowner_or_renter_credit: [280, 0] + +- name: 2024 Schedule 2EC, the one-claimant limit applies within each household, not across households + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + taxable_pension_income: 20_000 + real_estate_taxes: 2_000 + person2: + age: 75 + taxable_pension_income: 10_000 + rent: 6_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household1: + members: [person1] + state_name: MT + household2: + members: [person2] + state_name: MT + output: + # Household 1: line 18 = 20_000; line 20 = 7_400; line 21 = 0.035; + # line 22 = 259; line 27 = 2_000 - 259 = 1_741; line 28 = 1_150. + # Household 2: line 18 = 10_000; line 20 = 0; line 22 = 0; + # line 27 = 6_000 x 0.15 = 900. Each household has its own claimant. + mt_elderly_homeowner_or_renter_credit_selected_claimant: [true, true] + mt_elderly_homeowner_or_renter_credit: [1_150, 900] + +- name: 2023 dependent self-employment income preserves the deductible half of SE tax + period: 2023 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_tax_unit_dependent: false + taxable_pension_income: 20_000 + rent: 12_000 + child: + age: 18 + is_tax_unit_dependent: true + self_employment_income: 10_000 + tax_units: + claimant_unit: + members: [claimant, child] + households: + household: + members: [claimant, child] + state_code: MT + output: + # 2023 Schedule SE, PDF p. 1, lines 4a and 10-13: + # https://www.irs.gov/pub/irs-prior/f1040sse--2023.pdf#page=1 + # 10,000 * .9235 * (.124 + .029) / 2 = 706.4775 deductible SE tax. + # 2023 MT instructions, PDF pp. 51-52, lines 1 and 9: use each + # member's federal AGI and add back losses, preserving other adjustments. + # https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=51 + # Income = 20,000 + 10,000 - 706.4775 = 29,293.5225. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [20_000, 9_293.5225] + # Credit = 12,000 * .15 - (29,293.5225 - 12,600) * .05 = 965.323875. + mt_elderly_homeowner_or_renter_credit: [965.323875, 0] + - name: 2024 Schedule 2EC worked case, a household member's federal EITC counts as a refundable credit received period: 2024 absolute_error_margin: 0.01 @@ -580,3 +799,46 @@ # = 1_099.89; line 21 = 100%; line 22 = 1_099.89. mt_elderly_homeowner_or_renter_credit_net_household_income: [900.11, 0] mt_elderly_homeowner_or_renter_credit: [1_099.89, 0] + +- name: Federal refundable credits and actual receipts reduce both elderly claims before selecting the larger one + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + taxable_pension_income: 10_000 + rent: 4_800 + mt_elderly_homeowner_or_renter_credit_received: 60 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 20 + person2: + age: 70 + taxable_pension_income: 15_000 + rent: 7_200 + mt_elderly_homeowner_or_renter_credit_received: 40 + mt_elderly_homeowner_or_renter_credit_property_tax_rebate_received: 80 + tax_units: + tax_unit1: + members: [person1] + income_tax_refundable_credits: 500 + tax_unit2: + members: [person2] + income_tax_refundable_credits: 300 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Schedule 2EC line 8 counts each return's federal refundable credits + # once, plus actual elderly-credit and rebate receipts. Person amounts: + # 10_000 + 500 + 60 + 20 = 10_580; 15_000 + 300 + 40 + 80 = 15_420. + # Line 18 = 26_000; line 22 = (26_000 - 12_600) * .05 = 670. + # Line 25 rent equivalents = [4_800, 7_200] * .15 = [720, 1_080]. + # Line 27 = [720, 1_080] - 670 = [50, 410], both below the 1_150 cap. + # Line 29 = 100% below 35_000. Only the larger 410 claim is paid; + # without the 1_000 of federal credits/receipts, it would be 460. + mt_elderly_homeowner_or_renter_credit_gross_household_income: [10_580, 15_420] + mt_elderly_homeowner_or_renter_credit_net_household_income: [670, 670] + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: [50, 410] + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true] + mt_elderly_homeowner_or_renter_credit: [0, 410] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml new file mode 100644 index 00000000000..533e31da039 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.yaml @@ -0,0 +1,37 @@ +- name: Case 1, each eligible tax unit's credit before the one-claimant limit. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + taxable_pension_income: 10_000 + rent: 4_800 + person2: + age: 70 + taxable_pension_income: 15_000 + rent: 7_200 + person3: + age: 40 + taxable_interest_income: 5_000 + rent: 3_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_name: MT + output: + # 2024 Schedule 2EC. Line 18 = 10_000 + 15_000 + 5_000 = 30_000 for the + # whole household; line 20 = 17_400; line 21 = 0.05; line 22 = 870. + # person1: line 25 = 720; line 27 = max(720 - 870, 0) = 0. + # person2: line 25 = 1_080; line 27 = 1_080 - 870 = 210. + # person3 is under 62, so not a claimant (§ 15-30-2338(1)(a)). + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: [0, 210, 0] + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true, false] + mt_elderly_homeowner_or_renter_credit: [0, 210, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.yaml new file mode 100644 index 00000000000..31b6c0d4192 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/credits/elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.yaml @@ -0,0 +1,226 @@ +- name: Case 1, two eligible tax units in one household; the larger credit claims. + period: 2024 + input: + people: + person1: + age: 66 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 100 + person2: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 460 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true] + +- name: Case 2, equal credits; the first tax unit in member order claims. + period: 2024 + input: + people: + person1: + age: 68 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 280 + person2: + age: 72 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 280 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [true, false] + +- name: Case 3, an ineligible tax unit is never selected, whatever its amount. + period: 2024 + input: + people: + person1: + age: 58 + mt_elderly_homeowner_or_renter_credit_eligible: false + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 900 + person2: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 100 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true] + +- name: Case 4, no eligible tax unit in the household. + period: 2024 + input: + people: + person1: + age: 45 + mt_elderly_homeowner_or_renter_credit_eligible: false + person2: + age: 50 + mt_elderly_homeowner_or_renter_credit_eligible: false + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, false] + +- name: Case 5, three eligible tax units; the largest credit claims wherever it is listed. + period: 2024 + input: + people: + person1: + age: 64 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 100 + person2: + age: 80 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 900 + person3: + age: 75 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 460 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true, false] + +- name: Case 6, the limit is per household; each household selects its own claimant. + period: 2024 + input: + people: + person1: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 1_150 + person2: + age: 75 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 900 + person3: + age: 66 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 300 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + households: + household1: + members: [person1] + state_code: MT + household2: + members: [person2, person3] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [true, true, false] + +- name: Case 7, a joint couple is one tax unit and one claim. + period: 2025 + input: + people: + person1: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 1_150 + person2: + age: 66 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 0 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: true + +- name: Case 8, only tax unit heads are ranked; another member's amount does not count. + period: 2024 + input: + people: + person1: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 100 + person2: + # A dependent: the model holds a tax unit's credit on its head, so + # this amount, set here only to test the ranking, is ignored. + age: 12 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 900 + person3: + age: 67 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 200 + tax_units: + tax_unit1: + members: [person1, person2] + tax_unit2: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: [false, true] + +- name: Case 9, outside Montana no tax unit is selected. + period: 2024 + input: + people: + person1: + age: 70 + mt_elderly_homeowner_or_renter_credit_eligible: true + mt_elderly_homeowner_or_renter_credit_pre_one_claimant: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: ID + output: + mt_elderly_homeowner_or_renter_credit_selected_claimant: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml index 6b5b7e020f8..a9033e68da4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/mt_tax_unit_itemizes.yaml @@ -15,3 +15,41 @@ mt_standard_deduction_indiv: 10_000 output: mt_tax_unit_itemizes: false + +- name: Combined-AGI care phaseout keeps itemized deductions below the standard deduction + period: 2022 + input: + people: + head: + age: 40 + mt_agi_indiv: 20_000 + real_estate_taxes: 4_650 + spouse: + age: 40 + mt_agi_indiv: 0 + child: + age: 5 + tax_units: + tax_unit: + members: [head, spouse, child] + filing_status: JOINT + tax_unit_childcare_expenses: 3_000 + households: + household: + members: [head, spouse, child] + state_code: MT + output: + # 2022 Form 2441-M, PDF p. 1, lines 2-7: min(3,000, 2,400) + # - .5 * (20,000 - 18,000) = 1,400; half to each spouse = 700. + # https://revenue.mt.gov/files/Forms/Child-and-Dependent-Care-Expense-Deduction-Form-2441-M/2022_Child_and_Dependent_Care_Expense_Deduction_Form_2441-M.pdf#page=1 + mt_child_dependent_care_expense_deduction: [700, 700, 0] + # 2022 Form 2 instructions, PDF p. 35: standard deductions are + # max(min(.2 * 20,000, 5,090), 2,260) = 4,000 and the 2,260 floor. + # https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=35 + # Itemized total = 4,650 + 700 + 700 = 6,050 < 4,000 + 2,260 = 6,260. + # Using each spouse's AGI would give the spouse 1,200 and total 6,550, + # incorrectly switching the couple to itemization. + mt_itemized_deductions_indiv: [5_350, 700, 0] + mt_standard_deduction_indiv: [4_000, 2_260, 0] + mt_tax_unit_itemizes: false + mt_deductions_indiv: [4_000, 2_260, 0] diff --git a/policyengine_us/tests/test_mt_elderly_credit_household_members.py b/policyengine_us/tests/test_mt_elderly_credit_household_members.py index d1c1fff15ef..2d551fb39fd 100644 --- a/policyengine_us/tests/test_mt_elderly_credit_household_members.py +++ b/policyengine_us/tests/test_mt_elderly_credit_household_members.py @@ -10,16 +10,19 @@ and puts the income of "other members of your household" on line 17. The model reaches the same quantity by two paths. A member who is the -claimant's tax unit dependent is counted through dependent_gross_income and -dependent_taxable_social_security; a member who files their own return is -counted through federal AGI, the loss add-back and taxable_social_security. +claimant's tax unit dependent is counted through their income and permitted +adjustments; a member who files their own return is counted through federal +AGI, the loss add-back and taxable_social_security. For a claimant aged 62 or older (taxable pension, Social Security, property -tax, rent) and one other adult in the household (taxable interest, a -long-term capital gain or loss, Social Security): +tax, rent) and one other member in the household (taxable interest, a +long-term capital gain or loss, Social Security, self-employment profit and +permitted non-loss adjustments): 1. The household's gross household income is the claimant's pension and - Social Security plus the other member's interest, positive capital gain - and Social Security, whichever path counts the other member. + Social Security plus the other member's interest, positive capital gain, + self-employment profit and Social Security, less permitted non-loss + adjustments, whichever path counts the other member. Capital losses + cannot reduce this amount. 2. So the claimant's credit is the same in both arrangements, and the other member's own tax unit, with no one aged 62 or older, claims nothing. 3. The credit is between zero and the cap, and zero when gross household @@ -27,10 +30,15 @@ 4. More interest for the other member never raises the credit: gross household income rises, so net household income (line 22) cannot fall and the credit multiplier (line 29) cannot rise. +5. Early-withdrawal penalties and self-employed health/pension expenses + reduce household income by their deductible amounts, even when those + adjustments make the member's contribution negative. -No one has earned income, so no refundable credit enters line 8. The model -computes in single precision, so comparisons allow one cent or eight float32 -spacings at the household's total income, whichever is larger. +Earned-income cases keep the other member under 25 with no dependents on +their own return, so neither arrangement receives refundable earned-income +or child tax credits. The claimant has no earned income. The model computes +in single precision, so comparisons allow one cent or eight float32 spacings +at the household's total income, whichever is larger. """ import numpy as np @@ -42,12 +50,14 @@ CAP = 1_150 INCOME_LIMIT = 45_000 TOLERANCE = 0.01 +ARRANGEMENTS = "ABCD" def build_situation(cases): - """Three Montana households per case: (A) the other member is the + """Four Montana households per case: (A) the other member is the claimant's dependent, (B) the other member files their own return and - (C) as A with the other member's extra interest added.""" + (C) as A with the other member's extra interest added, and (D) as A + without early-withdrawal, self-employed health or pension expenses.""" people, tax_units, marital_units, spm_units, families, households = ( {}, {}, @@ -57,7 +67,7 @@ def build_situation(cases): {}, ) for i, c in enumerate(cases): - for arrangement in "ABC": + for arrangement in ARRANGEMENTS: key = f"{arrangement}{i}" claimant, member = f"claimant_{key}", f"member_{key}" interest = c["interest"] + ( @@ -75,17 +85,29 @@ def build_situation(cases): "taxable_interest_income": {YEAR: interest}, "long_term_capital_gains": {YEAR: c["capital_gain"]}, "social_security_survivors": {YEAR: c["member_ss"]}, + "self_employment_income": {YEAR: c["self_employment_income"]}, + "early_withdrawal_penalty": { + YEAR: 0 if arrangement == "D" else c["early_withdrawal_penalty"] + }, + "self_employed_health_insurance_premiums": { + YEAR: 0 if arrangement == "D" else c["health_premiums"] + }, + "self_employed_pension_contributions": { + YEAR: 0 if arrangement == "D" else c["pension_contributions"] + }, } if arrangement == "B": tax_units[f"tax_unit_{key}"] = {"members": [claimant]} tax_units[f"tax_unit_{key}_member"] = {"members": [member]} else: tax_units[f"tax_unit_{key}"] = {"members": [claimant, member]} - # Set for everyone: an input for some people gives the others - # its default (false), not its formula. Without it the model - # takes an adult in the claimant's tax unit to be the spouse. - people[claimant]["is_tax_unit_dependent"] = {YEAR: False} - people[member]["is_tax_unit_dependent"] = {YEAR: arrangement != "B"} + # Complete roles distinguish adult dependents from spouses + # and keep a minor's separate return headed by that minor; + # age-based inference only considers adults as filers. + people[claimant]["tax_unit_role_input"] = {YEAR: "HEAD"} + people[member]["tax_unit_role_input"] = { + YEAR: "HEAD" if arrangement == "B" else "DEPENDENT" + } marital_units[f"marital_unit_{key}"] = {"members": [claimant]} marital_units[f"marital_unit_{key}_member"] = {"members": [member]} spm_units[f"spm_unit_{key}"] = {"members": [claimant, member]} @@ -124,22 +146,59 @@ def calculate(cases): simulation.calculate("mt_elderly_homeowner_or_renter_credit", YEAR) ) dependent = np.asarray(simulation.calculate("is_tax_unit_dependent", YEAR)) - # Households and people are in situation order: A0, B0, C0, A1, ...; + other_refundable_credits = np.asarray( + simulation.calculate( + "mt_refundable_credits_before_renter_credit", YEAR, map_to="household" + ) + ) + federal_refundable_credits = np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit_federal_refundable_credits", + YEAR, + map_to="household", + ) + ) + # Households and people are in situation order: A0, B0, C0, D0, A1, ...; # the member follows the claimant. - n = len(cases) + n, arrangements = len(cases), len(ARRANGEMENTS) return { - "gross_household_income": gross_household_income.reshape(n, 3).T, - "credit": credit.reshape(n, 3).T, - "member_credit": person_credit[1::2].reshape(n, 3).T, - "member_is_dependent": dependent[1::2].reshape(n, 3).T, + "gross_household_income": gross_household_income.reshape(n, arrangements).T, + "credit": credit.reshape(n, arrangements).T, + "member_credit": person_credit[1::2].reshape(n, arrangements).T, + "member_is_dependent": dependent[1::2].reshape(n, arrangements).T, + "other_refundable_credits": other_refundable_credits.reshape(n, arrangements).T, + "federal_refundable_credits": federal_refundable_credits.reshape( + n, arrangements + ).T, } -def expected_gross_household_income(c, extra_interest=0): - """2024 Schedule 2EC line 18: lines 5 and 6 for the claimant, and line - 17 for the other member's interest, gains and Social Security.""" +def elective_adjustments(c): + # Generated health premiums and pension contributions stay below the + # member's eligible self-employment earnings, so all are deductible. + return ( + c["early_withdrawal_penalty"] + + c["health_premiums"] + + c["pension_contributions"] + ) + + +def expected_gross_household_income(c): + """All members' federal AGI without losses, plus nontaxable income: + MCA 15-30-2337(9); 2023 Form 2 instructions PDF pp. 51-52. + https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=51 + """ claimant = c["pension"] + c["claimant_ss"] - member = c["interest"] + extra_interest + max(0, c["capital_gain"]) + member = c["interest"] + max(0, c["capital_gain"]) + c["self_employment_income"] + # Schedule SE lines 4a, 4c, 10-13: taxable earnings are 92.35% of + # profit; no SE tax below $400. Generated profits stay below the + # Social Security wage base, and no one has wages. + # https://www.irs.gov/pub/irs-prior/f1040sse--2024.pdf#page=1 + taxable_earnings = c["self_employment_income"] * 0.9235 + half_se_tax = ( + taxable_earnings * (0.124 + 0.029) * 0.5 if taxable_earnings >= 400 else 0 + ) + member -= half_se_tax + elective_adjustments(c) return claimant + member + c["member_ss"] @@ -149,33 +208,44 @@ def tolerance(amount): def assert_properties(cases): result = calculate(cases) - a, b, c_ = 0, 1, 2 - # The other member is a dependent in A and C and their own filer in B. + a, b, c_, d = 0, 1, 2, 3 + # Equivalence assumes these income definitions contain no refundable + # credits whose eligibility changes when the member files separately. + assert np.all(result["other_refundable_credits"] == 0), cases + assert np.all(result["federal_refundable_credits"] == 0), cases + # The other member is a dependent in A, C and D and their own filer in B. assert result["member_is_dependent"].tolist() == [ [True] * len(cases), [False] * len(cases), [True] * len(cases), + [True] * len(cases), ] for i, c in enumerate(cases): expected = expected_gross_household_income(c) - tol = tolerance(expected + c["extra_interest"]) + expenses = elective_adjustments(c) + tol = tolerance(expected + c["extra_interest"] + expenses) ghi = result["gross_household_income"] credit = result["credit"] # 1. Both paths give the Schedule 2EC total. assert ghi[a, i] == pytest.approx(expected, abs=tol), c assert ghi[b, i] == pytest.approx(expected, abs=tol), c assert ghi[c_, i] == pytest.approx(expected + c["extra_interest"], abs=tol), c + assert ghi[d, i] == pytest.approx(expected + expenses, abs=tol), c # 2. The claimant's credit does not depend on the arrangement, and # the other member claims nothing. assert credit[a, i] == pytest.approx(credit[b, i], abs=tol), c assert np.all(result["member_credit"][:, i] == 0), c - for arrangement in (a, b, c_): + for arrangement in (a, b, c_, d): # 3. Bounds and the income limit. assert -tol <= credit[arrangement, i] <= CAP + tol, c if ghi[arrangement, i] >= INCOME_LIMIT + tol: assert credit[arrangement, i] == 0, c # 4. More income never raises the credit. assert credit[c_, i] <= credit[a, i] + tol, c + # 5. Non-loss adjustments lower income by the full deductible + # amount, including when the member has negative AGI without losses. + assert ghi[d, i] - ghi[a, i] == pytest.approx(expenses, abs=tol), c + assert credit[d, i] <= credit[a, i] + tol, c def test_worked_example(): @@ -194,16 +264,62 @@ def test_worked_example(): "interest": 10_000, "capital_gain": 0, "member_ss": 0, + "self_employment_income": 0, + "early_withdrawal_penalty": 0, + "health_premiums": 0, + "pension_contributions": 0, "extra_interest": 5_000, } result = calculate([case]) assert result["gross_household_income"][:, 0] == pytest.approx( - [30_000, 30_000, 35_000], abs=TOLERANCE + [30_000, 30_000, 35_000, 30_000], abs=TOLERANCE + ) + assert result["credit"][:, 0] == pytest.approx( + [1_130, 1_130, 352, 1_130], abs=TOLERANCE ) - assert result["credit"][:, 0] == pytest.approx([1_130, 1_130, 352], abs=TOLERANCE) assert_properties([case]) +def negative_household_cases(): + return [ + { + "claimant_age": claimant_age, + "pension": 0, + "claimant_ss": 347, + "property_tax": 319, + "rent": 0, + "member_age": 43, + "interest": interest, + "capital_gain": 0, + "member_ss": member_ss, + "self_employment_income": 0, + "early_withdrawal_penalty": penalty, + "health_premiums": 0, + "pension_contributions": 0, + "extra_interest": extra_interest, + } + for claimant_age, interest, member_ss, penalty, extra_interest in ( + (62, 4_420, 894, 5_986, 377), + (86, 0, 0, 894, 1), + ) + ] + + +def test_negative_household_income_keeps_full_multiplier(): + # 347 + 4,420 + 894 - 5,986 = -325; adding 377 gives 52; + # removing the penalty gives 5,661. In the second case, 347 - 894 + # = -547, or -546 with extra interest, or 347 without the penalty. + # Every total is below 12,600, so net household contribution is zero. + # Line 29 gives 100% below 35,000, so the 319 property tax is paid: + # https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=49 + result = calculate(negative_household_cases()) + assert result["gross_household_income"] == pytest.approx( + np.array([[-325, -547], [-325, -547], [52, -546], [5_661, 347]]), + abs=TOLERANCE, + ) + assert result["credit"] == pytest.approx(np.full((4, 2), 319), abs=TOLERANCE) + + try: import hypothesis import hypothesis.strategies as st @@ -216,26 +332,125 @@ def test_worked_example(): SLOW = [hypothesis.HealthCheck.too_slow] def amount(high): + if high == 0: + return st.just(0) return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) @st.composite def case(draw): + member_age = draw(st.integers(min_value=15, max_value=61)) + self_employment_income = draw(amount(20_000)) if member_age <= 24 else 0 return { "claimant_age": draw(st.integers(min_value=62, max_value=90)), "pension": draw(amount(40_000)), "claimant_ss": draw(amount(30_000)), "property_tax": draw(st.integers(min_value=0, max_value=4_000)), "rent": draw(amount(12_000)), - "member_age": draw(st.integers(min_value=15, max_value=61)), + "member_age": member_age, "interest": draw(amount(40_000)), "capital_gain": draw( st.one_of(st.just(0), st.integers(min_value=-20_000, max_value=40_000)) ), "member_ss": draw(amount(20_000)), + "self_employment_income": self_employment_income, + "early_withdrawal_penalty": draw(amount(40_000)), + # Keep these expenses within earned income and conservative + # plan limits, so both filer arrangements permit the deduction. + "health_premiums": draw(amount(self_employment_income // 4)), + "pension_contributions": draw(amount(self_employment_income // 10)), "extra_interest": draw(st.integers(min_value=1, max_value=20_000)), } - @hypothesis.settings(max_examples=15, deadline=None, suppress_health_check=SLOW) + @hypothesis.settings( + max_examples=15, derandomize=True, deadline=None, suppress_health_check=SLOW + ) + @hypothesis.example(negative_household_cases()) + @hypothesis.example( + [ + # A minor can head their own return. With no gross income, + # a deductible penalty of 1 gives AGI -1 in both arrangements. + { + "claimant_age": 62, + "pension": 0, + "claimant_ss": 0, + "property_tax": 0, + "rent": 0, + "member_age": 15, + "interest": 0, + "capital_gain": 0, + "member_ss": 0, + "self_employment_income": 0, + "early_withdrawal_penalty": 1, + "health_premiums": 0, + "pension_contributions": 0, + "extra_interest": 1, + } + ] + ) + @hypothesis.example( + [ + # P2: 10,000 * .9235 * .153 / 2 = 706.4775; + # gross household income = 20,000 + 10,000 - 706.4775. + { + "claimant_age": 70, + "pension": 20_000, + "claimant_ss": 0, + "property_tax": 0, + "rent": 12_000, + "member_age": 18, + "interest": 0, + "capital_gain": 0, + "member_ss": 0, + "self_employment_income": 10_000, + "early_withdrawal_penalty": 0, + "health_premiums": 0, + "pension_contributions": 0, + "extra_interest": 1_000, + }, + # The capital loss is excluded. 3,000 * .9235 * .153 / 2 + # = 211.94325; the other adjustments total 5,700, leaving + # member income 3,500 - 211.94325 - 5,700 = -2,411.94325. + { + "claimant_age": 70, + "pension": 20_000, + "claimant_ss": 0, + "property_tax": 0, + "rent": 12_000, + "member_age": 24, + "interest": 500, + "capital_gain": -3_000, + "member_ss": 0, + "self_employment_income": 3_000, + "early_withdrawal_penalty": 5_000, + "health_premiums": 500, + "pension_contributions": 200, + "extra_interest": 1_000, + }, + ] + ) + @hypothesis.example( + [ + # Taxable SE earnings: 433 * .9235 = 399.8755 (no tax), + # 434 * .9235 = 400.799 (half-tax deduction 30.6611235). + { + "claimant_age": 70, + "pension": 20_000, + "claimant_ss": 0, + "property_tax": 0, + "rent": 12_000, + "member_age": 18, + "interest": 0, + "capital_gain": 0, + "member_ss": 0, + "self_employment_income": profit, + "early_withdrawal_penalty": 0, + "health_premiums": 0, + "pension_contributions": 0, + "extra_interest": 1_000, + } + for profit in (433, 434) + ] + ) @hypothesis.given(st.lists(case(), min_size=1, max_size=8)) def test_properties(cases): assert_properties(cases) diff --git a/policyengine_us/tests/test_mt_elderly_credit_one_claimant.py b/policyengine_us/tests/test_mt_elderly_credit_one_claimant.py new file mode 100644 index 00000000000..d1dc874a300 --- /dev/null +++ b/policyengine_us/tests/test_mt_elderly_credit_one_claimant.py @@ -0,0 +1,299 @@ +"""Property tests: one claimant per household for the Montana elderly +homeowner/renter credit. + +MCA 15-30-2341(1): "Only one claimant per household in a claim period under +the provisions of 15-30-2337 through 15-30-2341 is entitled to relief." The +2024 and 2025 Schedule 2EC attestation reads "I am the only member of my +household claiming this credit". Gross household income (line 18) covers +everyone in the household, so every tax unit in a household with a member +aged 62 or older computes its credit on the same net household income (line +22) and multiplier (line 29); the credits differ only by each tax unit's own +property tax (line 23) and rent (line 24). The model pays the tax unit whose +credit is largest. + +A reference written here from the schedule's lines is compared with the +model for random Montana households of one to three tax units (single or +joint, any mix of ages, pension, interest, Social Security, property tax and +rent; no earned income, so no refundable credit enters line 8): + +1. Each tax unit's credit before the limit matches the reference. +2. The household's credit is the largest of its tax units' credits, so it + never exceeds the $1,150 cap of a single claim. +3. At most one tax unit per household is paid. Exactly one is selected in a + household with an eligible tax unit, and none in a household without one. +4. A tax unit is paid either nothing or its whole credit, and the selected + one is paid its whole credit. +5. Reversing the order of the tax units in a household does not change the + household's credit. + +The model computes in single precision, so comparisons allow one cent or +eight float32 spacings at the household's total income, whichever is larger. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system + +YEAR = 2024 +TOLERANCE = 0.01 +P = system.parameters(YEAR).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter + + +def build_situation(households): + """Each household is a list of tax units; each tax unit is a dict with + "adults" (a list of dicts with age, pension, interest and social + security) and the head's property tax and rent. Every household is built + twice: as given, and with its tax units in reverse order.""" + people, tax_units, marital_units, spm_units, families, situation_households = ( + {}, + {}, + {}, + {}, + {}, + {}, + ) + for h, units in enumerate(households): + for order, ordered_units in (("fwd", units), ("rev", units[::-1])): + key = f"{order}{h}" + members = [] + for u, unit in enumerate(ordered_units): + unit_key = f"{key}_u{u}" + unit_members = [] + for a, adult in enumerate(unit["adults"]): + person = f"{unit_key}_p{a}" + people[person] = { + "age": {YEAR: adult["age"]}, + "taxable_pension_income": {YEAR: adult["pension"]}, + "taxable_interest_income": {YEAR: adult["interest"]}, + "social_security_retirement": {YEAR: adult["social_security"]}, + "is_tax_unit_dependent": {YEAR: False}, + } + unit_members.append(person) + # The first adult is the oldest, so the head: the property + # tax and rent are theirs. + head = unit_members[0] + people[head]["real_estate_taxes"] = {YEAR: unit["property_tax"]} + people[head]["rent"] = {YEAR: unit["rent"]} + tax_units[unit_key] = {"members": unit_members} + marital_units[unit_key] = {"members": unit_members} + members += unit_members + spm_units[key] = {"members": members} + families[key] = {"members": members} + situation_households[key] = { + "members": members, + "state_code": {YEAR: "MT"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "families": families, + "households": situation_households, + } + + +def reference(units): + """Each tax unit's credit before the one-claimant limit, from the 2024 + Schedule 2EC lines.""" + # Line 18: the pension, interest and Social Security of everyone in the + # household. + gross = sum( + adult["pension"] + adult["interest"] + adult["social_security"] + for unit in units + for adult in unit["adults"] + ) + # Lines 19-22. + reduced = max(gross - P.net_household_income.standard_exclusion, 0) + net = reduced * float(P.net_household_income.reduction_rate.calc(reduced)) + # Line 29. + multiplier = float(P.multiplier.calc(gross)) + credits = [] + for unit in units: + eligible = max(adult["age"] for adult in unit["adults"]) >= P.age_threshold + # Lines 23-28. + housing = unit["property_tax"] + unit["rent"] * P.rent_equivalent_tax_rate + capped = min(max(housing - net, 0), P.cap) + credits.append(eligible * capped * multiplier) + return gross, credits + + +def tolerance(amount): + return max(TOLERANCE, 8 * float(np.spacing(np.float32(abs(amount))))) + + +def calculate(households): + """Per tax unit, in situation order: the credit before the limit, the + selection and the credit.""" + simulation = Simulation(situation=build_situation(households)) + + def by_tax_unit(variable): + return np.asarray(simulation.calculate(variable, YEAR, map_to="tax_unit")) + + return { + "pre_one_claimant": by_tax_unit( + "mt_elderly_homeowner_or_renter_credit_pre_one_claimant" + ), + "selected": np.asarray( + simulation.calculate( + "mt_elderly_homeowner_or_renter_credit_selected_claimant", YEAR + ) + ), + "credit": by_tax_unit("mt_elderly_homeowner_or_renter_credit"), + } + + +def assert_properties(households): + result = calculate(households) + start = 0 + for units in households: + n = len(units) + gross, expected = reference(units) + tol = tolerance(gross) + totals = {} + for order, ordered_expected in (("fwd", expected), ("rev", expected[::-1])): + pre = result["pre_one_claimant"][start : start + n] + selected = result["selected"][start : start + n] + credit = result["credit"][start : start + n] + start += n + # 1. Each tax unit's credit before the limit. + assert pre == pytest.approx(ordered_expected, abs=tol), units + # 2. The household is paid its largest credit, within the cap. + totals[order] = credit.sum() + assert totals[order] == pytest.approx(max(expected), abs=tol), units + assert -tol <= totals[order] <= P.cap + tol, units + # 3. At most one payment; one selection where any tax unit is + # eligible. + assert (credit > tol).sum() <= 1, units + any_eligible = any( + max(adult["age"] for adult in unit["adults"]) >= P.age_threshold + for unit in units + ) + assert selected.sum() == int(any_eligible), units + # 4. Nothing or the whole credit; the selected unit gets it all. + assert np.all((np.abs(credit) <= tol) | (np.abs(credit - pre) <= tol)), ( + units + ) + assert credit[selected] == pytest.approx(pre[selected], abs=tol), units + # 5. The order of the tax units does not matter. + assert totals["fwd"] == pytest.approx(totals["rev"], abs=tol), units + + +def adult(age, pension=0, interest=0, social_security=0): + return { + "age": age, + "pension": pension, + "interest": interest, + "social_security": social_security, + } + + +def test_worked_example(): + """2024 Schedule 2EC: two siblings, 66 and 70, filing separately in one + household with pensions of 10,000 and 15,000 and rent of 4,800 and + 7,200. Line 18 = 25,000; line 22 = 12,400 x 0.05 = 620. Line 27 is + 720 - 620 = 100 for the first and 1,080 - 620 = 460 for the second. + Only the second is paid; before the limit the household received 560.""" + households = [ + [ + {"adults": [adult(66, pension=10_000)], "property_tax": 0, "rent": 4_800}, + {"adults": [adult(70, pension=15_000)], "property_tax": 0, "rent": 7_200}, + ] + ] + result = calculate(households) + # Forward order, then reversed. + assert result["pre_one_claimant"] == pytest.approx( + [100, 460, 460, 100], abs=TOLERANCE + ) + assert result["selected"].tolist() == [False, True, True, False] + assert result["credit"] == pytest.approx([0, 460, 460, 0], abs=TOLERANCE) + assert_properties(households) + + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + # Each example builds a Simulation; on a loaded runner input generation + # can trip the too_slow health check, which says nothing about the model. + SLOW = [hypothesis.HealthCheck.too_slow] + + def amount(high): + return st.one_of(st.just(0), st.integers(min_value=1, max_value=high)) + + @st.composite + def person(draw): + return adult( + draw(st.integers(min_value=40, max_value=90)), + pension=draw(amount(25_000)), + interest=draw(amount(15_000)), + social_security=draw(amount(20_000)), + ) + + @st.composite + def tax_unit(draw): + adults = draw(st.lists(person(), min_size=1, max_size=2)) + # Put the oldest first so that the head holds the housing costs. + adults.sort(key=lambda a: -a["age"]) + if len(adults) == 2 and adults[0]["age"] == adults[1]["age"]: + adults[1]["age"] -= 1 + return { + "adults": adults, + "property_tax": draw(amount(4_000)), + "rent": draw(amount(12_000)), + } + + @hypothesis.settings( + max_examples=15, deadline=None, derandomize=True, suppress_health_check=SLOW + ) + @hypothesis.given( + st.lists( + st.lists(tax_unit(), min_size=1, max_size=3), + min_size=1, + max_size=4, + ) + ) + # Both tax units reach the $1,150 cap (line 18 = 20,000; line 22 = 259; + # line 27 = 2,741 and 1,741): one claim of 1,150. + @hypothesis.example( + [ + [ + { + "adults": [adult(70, pension=10_000)], + "property_tax": 3_000, + "rent": 0, + }, + { + "adults": [adult(66, pension=10_000)], + "property_tax": 2_000, + "rent": 0, + }, + ] + ] + ) + # A renter paying 6,000 (line 26 = 900) and an owner billed 1,000 (line + # 26 = 1,000): the owner's 741 is paid, not the renter's 641, although + # the renter pays more for housing. + @hypothesis.example( + [ + [ + { + "adults": [adult(70, pension=10_000)], + "property_tax": 0, + "rent": 6_000, + }, + { + "adults": [adult(66, pension=10_000)], + "property_tax": 1_000, + "rent": 0, + }, + ] + ] + ) + def test_properties(households): + assert_properties(households) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py index f2cbf8e8162..b0f123f4552 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit.py @@ -9,41 +9,18 @@ class mt_elderly_homeowner_or_renter_credit(Variable): definition_period = YEAR defined_for = "mt_elderly_homeowner_or_renter_credit_eligible" reference = ( - "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0410/0150-0300-0230-0410.html", # 2024 Form 2, Schedule 2EC "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=10", ) def formula(person, period, parameters): - p = parameters( - period - ).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter - # § 15-30-2337(4) defines gross household income as "all income - # received by all individuals of a household", including members - # outside the claimant's return (2024 Schedule 2EC line 17), so the - # multiplier phase-out applies to the household's total income rather - # than each person's share. - gross_household_income = add( - person.household, - period, - ["mt_elderly_homeowner_or_renter_credit_gross_household_income"], + # Only one claimant per household is entitled to relief + # (§ 15-30-2341(1)). + credit = person( + "mt_elderly_homeowner_or_renter_credit_pre_one_claimant", period ) - # Get net_household_income and allocate it to the head - head = person("is_tax_unit_head", period) - net_household_income = add( - person.tax_unit, - period, - ["mt_elderly_homeowner_or_renter_credit_net_household_income"], + selected = person.tax_unit( + "mt_elderly_homeowner_or_renter_credit_selected_claimant", period ) - # Credit Computation - property_tax = add(person.tax_unit, period, ["real_estate_taxes"]) - rent = add(person.tax_unit, period, ["rent"]) - countable_rent = rent * p.rent_equivalent_tax_rate - countable_rent_and_property_tax = property_tax + countable_rent - uncapped_credit_unit = max_( - countable_rent_and_property_tax - net_household_income, 0 - ) - uncapped_credit = uncapped_credit_unit * head - capped_credit = min_(uncapped_credit, p.cap) - multiplier = p.multiplier.calc(gross_household_income) - return capped_credit * multiplier + return credit * selected diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py index a46ccf6f5c6..56053085070 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_net_household_income.py @@ -18,8 +18,10 @@ def formula(person, period, parameters): p = parameters( period ).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter - # Only one claim is allowed per household - # married taxpayer who are living apart may qualify for only one credit per year + # Only one claimant per household is entitled to relief + # (§ 15-30-2341(1)); mt_elderly_homeowner_or_renter_credit_selected_claimant + # picks the claimant. Married couples living apart may receive only + # one credit per year (ARM 42.4.302(3)(a)). standard_exclusion = p.net_household_income.standard_exclusion # Gross household income counts every member of the household, # including those outside the claimant's return (§ 15-30-2337(4); diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.py new file mode 100644 index 00000000000..aa8cd4ba1ff --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_pre_one_claimant.py @@ -0,0 +1,55 @@ +from policyengine_us.model_api import * + + +class mt_elderly_homeowner_or_renter_credit_pre_one_claimant(Variable): + value_type = float + entity = Person + label = "Montana Elderly Homeowner/Renter Credit before the one-claimant limit" + unit = USD + definition_period = YEAR + defined_for = "mt_elderly_homeowner_or_renter_credit_eligible" + reference = ( + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0400/0150-0300-0230-0400.html", + # 2024 Form 2, Schedule 2EC + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=10", + # 2024 instructions, line 29 Credit Multiplier Table + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=49", + ) + + def formula(person, period, parameters): + p = parameters( + period + ).gov.states.mt.tax.income.credits.elderly_homeowner_or_renter + # § 15-30-2337(4) defines gross household income as "all income + # received by all individuals of a household", including members + # outside the claimant's return (2024 Schedule 2EC line 17), so the + # multiplier phase-out applies to the household's total income rather + # than each person's share. + gross_household_income = add( + person.household, + period, + ["mt_elderly_homeowner_or_renter_credit_gross_household_income"], + ) + # Get net_household_income and allocate it to the head + head = person("is_tax_unit_head", period) + net_household_income = add( + person.tax_unit, + period, + ["mt_elderly_homeowner_or_renter_credit_net_household_income"], + ) + # Credit Computation + property_tax = add(person.tax_unit, period, ["real_estate_taxes"]) + rent = add(person.tax_unit, period, ["rent"]) + countable_rent = rent * p.rent_equivalent_tax_rate + countable_rent_and_property_tax = property_tax + countable_rent + uncapped_credit_unit = max_( + countable_rent_and_property_tax - net_household_income, 0 + ) + uncapped_credit = uncapped_credit_unit * head + capped_credit = min_(uncapped_credit, p.cap) + # The table gives 100% for all income less than $35,000, including + # negative AGI after permitted adjustments. Preserve signed income + # and floor only the lookup input at its first threshold. + multiplier = p.multiplier.calc(max_(0, gross_household_income)) + return capped_credit * multiplier diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.py new file mode 100644 index 00000000000..c28c01f485d --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/mt_elderly_homeowner_or_renter/mt_elderly_homeowner_or_renter_credit_selected_claimant.py @@ -0,0 +1,64 @@ +from policyengine_us.model_api import * + + +class mt_elderly_homeowner_or_renter_credit_selected_claimant(Variable): + value_type = bool + entity = TaxUnit + label = "Selected claimant for the Montana Elderly Homeowner/Renter Credit" + definition_period = YEAR + defined_for = StateCode.MT + reference = ( + # § 15-30-2341(1): one claimant per household + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0410/0150-0300-0230-0410.html", + # § 15-30-2337(5), (7), (10): gross rent, household, property tax + # billed + "https://mca.legmt.gov/bills/mca/title_0150/chapter_0300/part_0230/section_0370/0150-0300-0230-0370.html", + # ARM 42.4.302 (implementing § 15-30-2341): property tax billed to + # another counts as rent; married couples living apart receive only + # one credit per year + "https://www.law.cornell.edu/regulations/montana/Mont-Admin-r-42.4.302", + # 2025 Schedule 2EC: the attestation "I am the only member of my + # household claiming this credit"; line 23 "the property tax you were + # billed"; line 24 "the rent that you paid" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2/2025_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=10", + # 2025 instructions: the attestation, the household definition and + # the joint filers' claimant + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2025_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=45", + # 2025 instructions, lines 23 and 24: property in another's name "can + # qualify as rent only"; "Your rent is only the amount of money that + # you paid to occupy your home" + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2025_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=47", + ) + + def formula(tax_unit, period, parameters): + # "Only one claimant per household in a claim period ... is entitled + # to relief" (§ 15-30-2341(1)). Neither the statute nor ARM 42.4.302 + # says which member claims. Every eligible tax unit in a household has + # the same gross and net household income, so their credits differ + # only by Schedule 2EC line 26: property tax billed plus 15% of rent, + # before the $1,150 cap. The model counts each tax unit's own + # property tax and rent, following Schedule 2EC's line labels ("the + # property tax you were billed", "the rent that you paid") and its + # instructions (property in another's name "can qualify as rent + # only"). The statute instead + # defines both for the homestead (§ 15-30-2337(5), (10)), which would + # give the one claimant the household's whole property tax and rent; + # that reading is not modeled. We select the tax unit whose credit is + # largest, the claim a household would choose. get_rank ranks the + # candidates in each household 0, 1, 2, ..., so a household with any + # candidate has exactly one at rank 0; its stable sort gives a tie to the first in member order. A + # joint return is one tax unit and one claim; the 2025 instructions + # name the spouse listed as the taxpayer as the claimant when both + # qualify, and the model holds that credit on the tax unit head. + # ARM 42.4.302(3)(a) also allows married couples living apart only + # one credit a year; a separated spouse in another tax unit is not + # linked to the other spouse, so that limit is not modeled. + person = tax_unit.members + head = person("is_tax_unit_head", period) + eligible = person("mt_elderly_homeowner_or_renter_credit_eligible", period) + credit = person( + "mt_elderly_homeowner_or_renter_credit_pre_one_claimant", period + ) + candidate = head & eligible + rank = person.get_rank(person.household, -credit, condition=candidate) + return tax_unit.any(candidate & (rank == 0))