diff --git a/changelog.d/filer-schedule-d-line16-rounding.fixed.md b/changelog.d/filer-schedule-d-line16-rounding.fixed.md new file mode 100644 index 00000000000..78fd2e51d9f --- /dev/null +++ b/changelog.d/filer-schedule-d-line16-rounding.fixed.md @@ -0,0 +1 @@ +Use the filers' shared Schedule D lines for Form 1040 capital gains, preserving supplied aggregate inputs without letting dependents' large gains introduce rounding errors. diff --git a/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md b/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md new file mode 100644 index 00000000000..7918a3f7217 --- /dev/null +++ b/changelog.d/mi-dependent-retirement-ssi-invariant.changed.md @@ -0,0 +1 @@ +Recognize the intended Michigan dependent-retirement SSI interaction in state tax invariants and add a regression for the resulting heating credit increase. diff --git a/changelog.d/mi-household-resources-claimant-spouse.fixed.md b/changelog.d/mi-household-resources-claimant-spouse.fixed.md new file mode 100644 index 00000000000..aef8da850af --- /dev/null +++ b/changelog.d/mi-household-resources-claimant-spouse.fixed.md @@ -0,0 +1 @@ +Michigan household resources count only the claimant's and spouse's own income, capital gains and Schedule 1 adjustments (MCL 206.508(3)), keeping the Social Security, SSI, railroad retirement, child support and public assistance received for dependents (MI-1040CR lines 21, 22 and 27) and premiums paid for the family (line 31). The filer's Form 1040 line 7a amount, read by Michigan and by federal net investment income, now keeps a net capital gains amount supplied for the tax unit, less only the dependents' own gains and losses. diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index d92aee43d73..df06e1c1e64 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -1,8 +1,12 @@ description: Michigan accounts for the following income sources as the household resources. values: 2021-01-01: + # Each source counts for the claimant and spouse only (MCL 206.508(3)), + # except those in household_resources_all_members (lines 21, 22 and 27). + # MI-1040CR line 14, wages, including strike pay. - irs_employment_income - strike_benefits + # MI-1040CR line 15, interest and dividends. - dividend_income - interest_income # MI-1040CR line 16, net business income: U.S. Schedule C, Form 4797 @@ -18,22 +22,35 @@ values: # as a total. - rental_income - farm_rent_income + # MI-1040CR line 18, pensions and annuities. - pension_income - retirement_benefits_from_ss_exempt_employment - - loss_limited_net_capital_gains + # MI-1040CR line 19, net capital gains and losses from the filer's U.S. + # Schedule D, a loss limited to $3,000 ($1,500 married filing separately). + - filer_loss_limited_net_capital_gains + # MI-1040CR line 20, alimony and other taxable income. - alimony_income - gambling_winnings + # MI-1040CR line 21, Social Security, SSI and railroad retirement + # benefits, including amounts received for dependents. - social_security - - ssi + - ssi - railroad_benefits + # MI-1040CR line 22, child support. - child_support_received + # MI-1040CR line 23, unemployment compensation. - unemployment_compensation - # Gifts received or expenses paid on your behalf + # MI-1040CR line 24, gifts received or expenses paid on your behalf. - miscellaneous_income + # MI-1040CR line 26, veterans' benefits and workers' compensation. - veterans_benefits - workers_compensation + # MI-1040CR line 14, long-term disability benefits. - disability_benefits + # Guaranteed income pilot payments, the recipient's own. - gi_cash_assistance + # MI-1040CR line 27, payments to the household by MDHHS and other public + # assistance. - tanf - general_assistance metadata: diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml new file mode 100644 index 00000000000..c08195a2bcf --- /dev/null +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources_all_members.yaml @@ -0,0 +1,30 @@ +description: Michigan counts these household resources sources for every tax unit member, not only the claimant and spouse, as amounts the claimant receives for others in the household. +values: + 2021-01-01: + # MI-1040CR line 21: Social Security, SSI and railroad retirement + # benefits, including "amounts received for minor children or other + # dependent adults who live with you". + - social_security + - ssi + - railroad_benefits + # MI-1040CR line 22: "child support and all payments received as a foster + # parent". + - child_support_received + # MI-1040CR line 27: "the total payments made to your household by MDHHS + # and all other public assistance payments". A Family Independence + # Program grant covers the children in the case. + - tanf + - general_assistance +metadata: + unit: list + period: year + label: Michigan household resources sources counted for every member + reference: + - title: MCL 206.508(3) and (4), household and total household resources + href: https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508 + - title: Michigan 2025 Individual Income Tax Instructions, MI-1040CR lines 21, 22 and 27 + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=32 + - title: Michigan 2024 Home Heating Credit Instructions, MI-1040CR-7 lines 26, 27 and 32 + href: https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf#page=8 + - title: Michigan 2025 Home Heating Credit Instructions, MI-1040CR-7 lines 24, 25 and 30 + href: https://web.archive.org/web/20260218211009/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=9 diff --git a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py index 6d484de82fb..49783dcfb0f 100644 --- a/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py +++ b/policyengine_us/tests/core/test_dependent_retirement_income_state_invariants.py @@ -28,12 +28,15 @@ 1. `tax_unit_non_dep_add` equals a numpy reference: the head's and spouse's amounts of person-level variables plus tax-unit-level variables. For tax units without dependents it equals `add`. -2. In every state with an income tax, giving the dependents retirement income - never lowers the filer's state income tax. Outside +2. Outside STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES, giving the + dependents retirement income never lowers the filer's state income tax. + Michigan is an intended exception: a dependent adult's pension can lower + their SSI and increase the claimant's refundable credit. Outside STATES_COUNTING_DEPENDENT_INCOME it changes neither the state AGI nor the state taxable income. (State income tax itself may still rise: credits keyed to household income, such as Oklahoma's sales tax relief credit, - count every household member's income.) + count every household member's income.) Michigan's pension benefits, + subtractions and tax before refundable credits must remain unchanged. 3. A unit whose dependents have no income has the same inputs in both populations, so its state income tax, AGI and taxable income are the same in both: no unit's results depend on another unit's inputs. @@ -368,6 +371,14 @@ def test_tax_unit_non_dep_add_edge_cases(): # subtracting income that never entered AGI; property 2 checks only that it # never lowers tax there. STATES_COUNTING_DEPENDENT_INCOME = {"AL", "AR", "IA", "MS", "NJ"} +# Intended exception per ruling d1143 (2026-10-09): follow MI-1040CR lines +# 18 and 21 (2025 MI-1040 booklet, PDF page 28 / printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 counts pensions only for the claimant and spouse; line 21 counts +# SSI received for dependent adults who live with them. A dependent adult's +# pension can reduce SSI, lower household resources and raise refundable +# credits, while the claimant's pension subtractions and tax base stay fixed. +STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES = {"MI"} YEARS = [2025, 2026] @@ -402,11 +413,32 @@ def test_dependents_retirement_income_never_reduces_state_income_tax(year): assert not leaked.any(), sorted(set(states[leaked])) lowered = tax_with < tax_without - TOLERANCE - assert not lowered.any(), sorted(set(states[lowered])) + intended_exception = np.isin( + states, list(STATES_WITH_INTENDED_RETIREMENT_TAX_DECREASES) + ) + unexpectedly_lowered = lowered & ~intended_exception + assert not unexpectedly_lowered.any(), sorted(set(states[unexpectedly_lowered])) counts_dependents = np.isin(states, list(STATES_COUNTING_DEPENDENT_INCOME)) changed &= ~counts_dependents assert not changed.any(), sorted(set(states[changed])) + + # Michigan's intended exception concerns refundable credits only; a + # dependent's pension must never enter the claimant's pension subtraction. + mi = states == "MI" + for variable in [ + "mi_pension_benefit", + "mi_subtractions", + "mi_income_tax_before_refundable_credits", + ]: + np.testing.assert_allclose( + with_income.calculate(variable, year)[mi], + without_income.calculate(variable, year)[mi], + rtol=0, + atol=TOLERANCE, + err_msg=f"Michigan {variable} changed with dependent retirement income", + ) + # The population must exercise the property, including the paths gated on # the filers' Social Security and on ages of 73 or more. assert has_dependent_income.sum() >= len(INCOME_TAX_STATES) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml index 1b8d0064fed..f5d12dd1015 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/capital_gains/net_capital_gain.yaml @@ -287,3 +287,186 @@ capital_gains_tax: 1_500 # The same filer income and HOH tax calculation as the child-dividends case. regular_tax_before_credits: 22_938 + + +- name: A dependent's very large gain cannot round a dollar off the filer's Schedule D + period: 2025 + input: + people: + head: + age: 30 + employment_income: 20_000 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: TX + output: + # The float32 all-member aggregate rounds 16,777,217 to 16,777,216. + # Subtracting the child's 16,776,240 would give 976, rather than 977. + # Schedule D line 16 and Form 1040 line 7a use the filer's own gain. + filer_loss_limited_net_capital_gains: 977 + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + adjusted_gross_income: 20_977 + +- name: Each spouse's distributions are floored before they enter the shared Schedule D + period: 2025 + input: + people: + head: + age: 35 + is_tax_unit_head: true + long_term_capital_gains: 6_000 + non_sch_d_capital_gains: -2_000 + spouse: + age: 34 + is_tax_unit_spouse: true + short_term_capital_gains: -3_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_unit: + members: [child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Line 13 is max(0, -2,000) + max(0, 1,500) = 1,500, with no child + # distributions. Line 15 is 7,500; line 7 is -3,000; line 16 is 4,500. + filing_status: JOINT + filer_loss_limited_net_capital_gains: 4_500 + net_capital_gain: 4_500 + dwks09: 4_500 + dwks10: 4_500 + +- name: A supplied aggregate still excludes the dependent in Form 1040 line 7a + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 6_000 + short_term_capital_gains: -1_000 + non_sch_d_capital_gains: 1_500 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 10_000 + short_term_capital_gains: -2_000 + non_sch_d_capital_gains: 9_000 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 15_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied 15,000 covers all members. Remove the child's 8,000 and + # add the head's 1,500 distributions: Schedule D line 16 is 8,500. + filer_loss_limited_net_capital_gains: 8_500 + # Preserve the documented aggregate/component split: line 15 is 7,500, + # so worksheet line 9 is min(7,500, 8,500), while the statutory amount + # uses component-derived lines 15 and 7: 7,500 - 1,000 = 6,500. + dwks09: 7_500 + dwks10: 7_500 + net_capital_gain: 6_500 + + +- name: A large supplied aggregate keeps the dollar in its component adjustment + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: -1 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 16_777_216 + households: + household: + members: [head] + state_code: TX + output: + # The aggregate is supplied, rather than the component-derived -1. + # Reconciling through a float32 difference from -1 rounds 16,777,217 + # down by a dollar. Read the differing supplied aggregate directly. + filer_loss_limited_net_capital_gains: 16_777_216 + # The component-derived line 15 remains a loss, so the preexisting + # aggregate/component split gives no statutory gain or worksheet gain. + net_capital_gain: 0 + dwks09: 0 + dwks10: 0 + +- name: A supplied aggregate removes a large dependent gain without borrowing its rounding error + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 977 + child: + age: 8 + is_tax_unit_dependent: true + long_term_capital_gains: 16_776_240 + tax_units: + tax_unit: + members: [head, child] + net_capital_gains: 20_000_000 + households: + household: + members: [head, child] + state_code: TX + output: + # The supplied all-member gain is 20,000,000. Removing the child's + # own 16,776,240 gives 3,223,760. A residual against the rounded + # computed aggregate (16,777,216) incorrectly adds a dollar to this. + filer_loss_limited_net_capital_gains: 3_223_760 + # Component-derived line 15 remains 977, so the preexisting + # aggregate/component split gives that gain to the worksheet and + # statutory net-capital-gain calculation. + net_capital_gain: 977 + dwks09: 977 + dwks10: 977 + +- name: A supplied capital gain survives a much larger filer component + period: 2025 + input: + people: + head: + age: 35 + long_term_capital_gains: 1_152_921_504_606_846_976 + tax_units: + tax_unit: + members: [head] + net_capital_gains: 1 + households: + household: + members: [head] + state_code: TX + output: + # The component gain is 2**60, exactly representable in float32. + # Even a float64 residual of 1 - 2**60 loses the supplied dollar when + # added back to the component gain. No dependent gains need removal. + filer_loss_limited_net_capital_gains: 1 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml index 473aa347a36..f5ea071528e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/tax/federal_income/dependent_net_investment_income.yaml @@ -401,3 +401,15 @@ net_investment_income: 6_000 # 3.8% x 6,000 net_investment_income_tax: 228 + +- name: A supplied net capital gains amount is net investment income + period: 2025 + input: + employment_income: 300_000 + net_capital_gains: 100_000 + state_code: TX + output: + # Form 8960 line 5a: the filer's Form 1040 line 7a, 100,000. + filer_loss_limited_net_capital_gains: 100_000 + net_investment_income: 100_000 + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml index 4ebed014ab9..46943e5822e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/credits/home_heating/mi_home_heating_credit.yaml @@ -184,3 +184,97 @@ state_code: MI output: mi_home_heating_credit: 0 + +# Ruling d1143 (2026-10-09): follow MI-1040CR lines 18 and 21. +# 2025 MI-1040 book, PDF page 28 (printed page 32): +# https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=28 +# Line 18 excludes a dependent adult's pension; line 21 includes their SSI. +- name: Dependent adult pension lowers Michigan tax through SSI (d1143) + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head_without_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + takes_up_ssi_if_eligible: false + dependent_without_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 0 + takes_up_ssi_if_eligible: true + head_with_pension: + age: 40 + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_dependent: false + takes_up_ssi_if_eligible: false + dependent_with_pension: + age: 75 + is_tax_unit_head: false + is_tax_unit_dependent: true + taxable_private_pension_income: 2_400 + takes_up_ssi_if_eligible: true + marital_units: + head_without_pension: + members: [head_without_pension] + dependent_without_pension: + members: [dependent_without_pension] + head_with_pension: + members: [head_with_pension] + dependent_with_pension: + members: [dependent_with_pension] + tax_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + heating_expenses: 0 + mi_home_heating_credit_heat_included_in_rent: false + mi_eitc: 0 + mi_homestead_property_tax_credit: 0 + spm_units: + without_pension: + members: [head_without_pension, dependent_without_pension] + tanf: 0 + with_pension: + members: [head_with_pension, dependent_with_pension] + tanf: 0 + families: + without_pension: + members: [head_without_pension, dependent_without_pension] + with_pension: + members: [head_with_pension, dependent_with_pension] + households: + without_pension: + members: [head_without_pension, dependent_without_pension] + state_code: MI + with_pension: + members: [head_with_pension, dependent_with_pension] + state_code: MI + output: + # Annual SSI: 12 * $967 = $11,604. Countable annual pension: + # $2,400 - 12 * $20 general exclusion = $2,160; $11,604 - $2,160 = $9,444. + ssi: [0, 11_604, 0, 9_444] + # Claimant wages + dependent SSI; the dependent's pension is excluded. + mi_household_resources: [21_604, 19_444] + adjusted_gross_income: [10_000, 10_000] + mi_pension_benefit: [0, 0] + mi_subtractions: [0, 0] + mi_taxable_income: [0, 0] + mi_income_tax_before_refundable_credits: [0, 0] + # Two exemptions: $815 - 3.5% * household resources. + mi_standard_home_heating_credit: [58.86, 134.46] + # Apply the 2025 60% funding factor: $58.86 * .60; $134.46 * .60. + mi_home_heating_credit: [35.316, 80.676] + # Only the heating credit changes: tax falls by $45.36, + # equal to $2,160 * 3.5% * 60% = $80.676 - $35.316. + mi_income_tax: [-35.316, -80.676] + state_income_tax: [-35.316, -80.676] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index 74cfed89ef5..9e9345ba1a7 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -8,12 +8,15 @@ # "If the total is negative enter 0." Line 30 is "total adjustments from your # U.S. Form 1040, Schedule 1", and line 31 is health insurance premiums. The # household is "a claimant and spouse" (MCL 206.508(3)), so a dependent's -# business and rental items stay off lines 16 and 17. +# income and Schedule 1 adjustments stay off the claimant's lines, except +# Social Security and SSI received for dependents (line 21), child support +# (line 22) and public assistance paid to the household (line 27). Premiums +# paid for the family (line 31) include dependents' coverage. - name: Cannot be negative period: 2024 input: - net_capital_gains: -2_000 + long_term_capital_gains: -2_000 state_code: MI health_insurance_premiums: 0 early_withdrawal_penalty: 1_000 @@ -27,7 +30,7 @@ - name: Reduced to 0 period: 2024 input: - net_capital_gains: 2_000 + long_term_capital_gains: 2_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -39,7 +42,7 @@ - name: Partially reduced period: 2024 input: - net_capital_gains: 10_000 + long_term_capital_gains: 10_000 state_code: MI health_insurance_premiums: 3_000 early_withdrawal_penalty: 1_000 @@ -724,3 +727,374 @@ # Schedule 1 line 11 and MI-1040CR line 30 use the 2025 cap of 300. # Line 33: 50,000 - 300 = 49,700. mi_household_resources: 49_700 + +- name: A working dependent's wages, interest and self-employment are not household resources + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + person2: + age: 17 + employment_income_before_lsr: 8_000 + taxable_interest_income: 200 + self_employment_income: 3_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant and spouse (MCL 206.508(3)); the form + # asks for "all taxable and nontaxable income you and your spouse + # received". The dependent's wages (8,000), interest (200) and Schedule C + # (3,000) are on the dependent's own return. + # Line 14: 40,000. Lines 15 and 16: 0. + # Line 30: the dependent's deduction for half of self-employment tax + # (3,000 x 0.9235 x 15.3% / 2 = 211.94) is on the dependent's own + # Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Both spouses' income counts and a child's does not + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 30_000 + person2: + age: 44 + employment_income_before_lsr: 20_000 + taxable_interest_income: 500 + person3: + age: 12 + taxable_interest_income: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + marital_units: + marital_unit: + members: [person1, person2] + child_marital_unit: + members: [person3] + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 30,000 + 20,000 = 50,000. Line 15: the spouse's 500; the + # child's 1,000 is the child's own income. + # Line 33: 50,500. + mi_household_resources: 50_500 + +- name: Social Security survivors benefits paid for a child count on line 21 + period: 2025 + input: + people: + person1: + age: 38 + employment_income_before_lsr: 30_000 + person2: + age: 10 + social_security_survivors: 9_000 + taxable_interest_income: 400 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 30,000. Line 15: 0; the child's 400 of interest is the + # child's own income. + # Line 21: "Include death benefits and amounts received for minor + # children or other dependent adults who live with you": 9,000. + # Line 33: 30,000 + 9,000 = 39,000. + mi_household_resources: 39_000 + +- name: SSI and child support received for a child count on lines 21 and 22 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 20_000 + person2: + age: 8 + ssi: 6_000 + child_support_received: 4_800 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 20,000. Line 21: SSI received for a minor child, 6,000. + # Line 22: "child support and all payments received as a foster + # parent", 4,800. + # Line 33: 20,000 + 6,000 + 4,800 = 30,800. + mi_household_resources: 30_800 + +- name: A dependent adult's Social Security counts but the dependent's pension does not + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 50_000 + person2: + age: 72 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + social_security_retirement: 15_000 + taxable_pension_income: 6_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + parent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 50,000. Line 18: 0; the dependent parent's pension is the + # parent's own income. + # Line 21: amounts received for "other dependent adults who live with + # you", 15,000. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A dependent's capital loss does not reduce the claimant's line 19 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + long_term_capital_gains: 2_000 + person2: + age: 16 + short_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 19: net capital gains "from your U.S. Schedule D", the claimant's + # 2,000. The dependent's -5,000 is on the dependent's own Schedule D. + # Line 33: 50,000 + 2,000 = 52,000. + mi_household_resources: 52_000 + +- name: A dependent's early withdrawal penalty is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + early_withdrawal_penalty: 1_000 + person2: + age: 16 + early_withdrawal_penalty: 500 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1": the + # claimant's penalty on early withdrawal of savings (Schedule 1 line 18), + # 1,000. The dependent's 500 is on the dependent's own Schedule 1. + # Line 33: 50,000 - 1,000 = 49,000. + mi_household_resources: 49_000 + +- name: A Family Independence Program grant for a parent and two children counts in full on line 27 + period: 2025 + input: + people: + person1: + age: 30 + employment_income_before_lsr: 10_000 + person2: + age: 6 + person3: + age: 4 + tax_units: + tax_unit: + members: [person1, person2, person3] + spm_units: + spm_unit: + members: [person1, person2, person3] + tanf: 6_000 + households: + household: + members: [person1, person2, person3] + state_code: MI + output: + # Line 14: 10,000. Line 27: "the total payments made to your household + # by MDHHS", the whole 6,000 grant, including the children's share. + # Line 33: 10,000 + 6,000 = 16,000. + mi_household_resources: 16_000 + +- name: Premiums paid for a child's coverage reduce household resources on line 31 + period: 2025 + input: + people: + person1: + age: 40 + employment_income_before_lsr: 40_000 + health_insurance_premiums: 2_400 + person2: + age: 9 + health_insurance_premiums: 1_200 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 31: "insurance premiums you paid for yourself and your family", + # 2,400 + 1,200 = 3,600, reading the child's premium as one the claimant + # paid for the child's coverage. + # Line 33: 40,000 - 3,600 = 36,400. + mi_household_resources: 36_400 + +- name: A dependent's alimony payment is not on the claimant's line 30 + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + person2: + age: 30 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + alimony_expense: 2_000 + divorce_year: 2015 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. Line 30: the dependent's deductible alimony (a 2015 + # divorce) is on the dependent's own Schedule 1, so 0. + # Line 33: 40,000. + mi_household_resources: 40_000 + +- name: Guaranteed income pilot payments count only for the claimant and spouse + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + employment_income_before_lsr: 40_000 + gi_cash_assistance: 1_000 + person2: + age: 19 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + gi_cash_assistance: 7_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1] + dependent_marital_unit: + members: [person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 14: 40,000. The claimant's own pilot payment, 1,000, counts. The + # dependent's 7,000 is the dependent's own income: nothing shows it is a + # public assistance payment to the claimant's household (line 27). + # Line 33: 41,000. + mi_household_resources: 41_000 + +- name: A supplied net capital gains amount counts on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: 5_000 + state_code: MI + output: + # A tax unit net_capital_gains input is the filer's Schedule D amount. + # Line 14: 10,000. Line 19: 5,000. + filer_loss_limited_net_capital_gains: 5_000 + mi_household_resources: 15_000 + +- name: A supplied net capital loss is limited to $3,000 on line 19 + period: 2025 + input: + employment_income_before_lsr: 10_000 + net_capital_gains: -5_000 + state_code: MI + output: + # Line 19: "losses cannot exceed $3,000", so -3,000. + # Line 33: 10,000 - 3,000 = 7,000. + filer_loss_limited_net_capital_gains: -3_000 + mi_household_resources: 7_000 + +- name: Supplied Schedule 1 adjustment amounts come off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employment_tax_ald: 1_000 + self_employed_pension_contribution_ald: 1_000 + state_code: MI + output: + # Tax unit inputs for the deduction for self-employment tax and the SEP + # deduction are the filer's Schedule 1 amounts. + # Line 33: 10,000 - 1,000 - 1,000 = 8,000. + mi_household_resources: 8_000 + +- name: A supplied self-employed health insurance deduction comes off line 30 + period: 2025 + input: + employment_income_before_lsr: 10_000 + self_employed_health_insurance_ald: 1_000 + state_code: MI + output: + # Line 33: 10,000 - 1,000 = 9,000. + mi_household_resources: 9_000 diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py index 10e1d6d7594..b8e2af53f29 100644 --- a/policyengine_us/tests/test_mi_household_resources_losses.py +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -38,8 +38,8 @@ out on purpose: when line 16 is floored at zero, more Schedule C income still raises the deduction for self-employment tax on line 30, so household resources fall. The form gives that result. -6. A dependent's business and rental losses and S corporation, estate and - rental income leave household resources unchanged. +6. A dependent's business and rental income and losses, including Schedule C, + Schedule F, S corporation and estate income, leave household resources unchanged. Amounts are whole dollars of at most $500,000, and the self-employment tax deduction is read from the model. The model computes in single precision, so a @@ -312,10 +312,11 @@ def test_worked_example(): DEPENDENT_VALUES = { - # Losses only for Schedule C and F: a dependent's self-employment tax - # deduction is a separate question (it is not on this return either). - "self_employment_income": [-20_000, 0], - "farm_operations_income": [-10_000, 0], + # Schedule C and F income and losses: a dependent's self-employment tax + # deduction is on the dependent's own return, so line 30 leaves it out + # (#9801). + "self_employment_income": [-20_000, 0, 20_000], + "farm_operations_income": [-10_000, 0, 10_000], "s_corp_income": [-20_000, 0, 15_000], "estate_income": [-5_000, 0, 5_000], "rental_income": [-20_000, 0, 15_000], diff --git a/policyengine_us/tests/test_mi_household_resources_members.py b/policyengine_us/tests/test_mi_household_resources_members.py new file mode 100644 index 00000000000..fd9f10f8a5b --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_members.py @@ -0,0 +1,493 @@ +"""Property tests: whose amounts count in Michigan household resources. + +MCL 206.508(3): "'Household' means a claimant and spouse." MCL 206.508(4) +counts "all income received by all persons of a household". The MI-1040CR +asks for "all taxable and nontaxable income you and your spouse received" +(2025 MI-1040 book, page 31), so a dependent's own income and Schedule 1 +adjustments are on the dependent's return, not the claimant's. The form +counts amounts the claimant receives for others in the household on three +lines (page 32): + +- line 21: Social Security, SSI and railroad retirement benefits, including + "amounts received for minor children or other dependent adults who live + with you"; +- line 22: child support and foster parent payments; +- line 27: payments to the household by MDHHS and other public assistance. + +Line 31 is "insurance premiums you paid for yourself and your family"; a +premium on any member's record is read as one the claimant or spouse paid +for the family's coverage. + +So for every household: + +1. mi_household_resources equals a reference written from the form: the head + and spouse's lines 14 to 20 and 23 to 26 and their line 30 adjustments, + plus every member's lines 21, 22 and 27, less every member's premiums, + floored at zero. +2. A dependent's own income and adjustments do not matter: zeroing them + leaves household resources unchanged. +3. Amounts received for the household count wherever they are recorded: + moving a dependent's line 21, 22 or 27 amount, or premium, to the head + leaves household resources unchanged. +4. Raising a dependent's line 21, 22 or 27 amount by d raises household + resources by between 0 and d, and by exactly d when they were positive. +5. Each of the spouse's own sources adds what it adds on the head. +6. The lists are reformable: a source moved into + household_resources_all_members counts for every member, and an SPM unit + source in household_resources counts in full. + +Every generated household has a dependent, so properties 2 to 4 always bind, +and property 4 always includes a household with positive resources. + +Comparisons allow one cent or eight float32 spacings at the sum of the +household's absolute amounts, as in test_mi_household_resources_losses.py. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation + +YEAR = 2025 +CAPITAL_LOSS_LIMIT = 3_000 +TOLERANCE = 0.01 + +# Inputs that are the claimant's or spouse's own, by MI-1040CR line. +CLAIMANT_LINES = { + "employment_income_before_lsr": 14, + "strike_benefits": 14, + "disability_benefits": 14, + "taxable_interest_income": 15, + "tax_exempt_interest_income": 15, + "qualified_dividend_income": 15, + "self_employment_income": 16, + "s_corp_income": 16, + "rental_income": 17, + "taxable_pension_income": 18, + "long_term_capital_gains": 19, + "alimony_income": 20, + "gambling_winnings": 20, + "unemployment_compensation": 23, + "miscellaneous_income": 24, + "veterans_benefits": 26, + "workers_compensation": 26, + # Guaranteed income pilot payments: the recipient's own (line 25). + "gi_cash_assistance": 25, + # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18). + "early_withdrawal_penalty": 30, +} +# Inputs the claimant receives for the household, by MI-1040CR line. +HOUSEHOLD_LINES = { + "social_security_retirement": 21, + "social_security_survivors": 21, + "ssi": 21, + "railroad_benefits": 21, + "child_support_received": 22, + "general_assistance": 27, +} +PREMIUMS = "health_insurance_premiums" +# Sources that can be negative. +SIGNED = {"self_employment_income", "s_corp_income", "rental_income"} +SIGNED.add("long_term_capital_gains") +PERSON_INPUTS = [*CLAIMANT_LINES, *HOUSEHOLD_LINES, PREMIUMS] +# Person-level line 30 amounts read from the model. +PERSON_ADJUSTMENTS = [ + "self_employment_tax_ald_person", + "self_employed_health_insurance_ald_person", +] + + +def zero_person(): + return {name: 0 for name in PERSON_INPUTS} + + +def build_situation(households): + """One Michigan tax unit per household: a head aged 45, an optional + spouse aged 44, and dependents aged 10 or 16 (children) or 75 (a + dependent parent). The SPM unit is the tax unit, and its Family + Independence Program grant (tanf) is an input, so the model does not + compute one from the household's income.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + spm_units = {} + for i, h in enumerate(households): + members, couple = [], [] + roles = [("head", h["head"])] + if h.get("spouse") is not None: + roles.append(("spouse", h["spouse"])) + roles += [("dependent", d) for d in h.get("dependents", [])] + for j, (role, p) in enumerate(roles): + name = f"person_{i}_{j}" + age = {"head": 45, "spouse": 44}.get(role, p.get("age", 10)) + person = {"age": {YEAR: age}} + for variable in PERSON_INPUTS: + person[variable] = {YEAR: p.get(variable, 0)} + # Roles are set for everyone: an adult dependent would otherwise + # be the spouse, and a variable input for some people gives the + # others its default value, not its formula. + person["is_tax_unit_head"] = {YEAR: role == "head"} + person["is_tax_unit_spouse"] = {YEAR: role == "spouse"} + person["is_tax_unit_dependent"] = {YEAR: role == "dependent"} + people[name] = person + members.append(name) + if role == "dependent": + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} + else: + couple.append(name) + tax_units[f"tax_unit_{i}"] = {"members": members} + spm_units[f"spm_unit_{i}"] = { + "members": members, + "tanf": {YEAR: h.get("tanf", 0)}, + } + marital_units[f"marital_unit_{i}"] = {"members": couple} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "households": households_out, + } + + +def filers(h): + return [h["head"]] + ([h["spouse"]] if h.get("spouse") is not None else []) + + +def everyone(h): + return filers(h) + list(h.get("dependents", [])) + + +def calculate(households): + simulation = Simulation(situation=build_situation(households)) + result = np.asarray(simulation.calculate("mi_household_resources", YEAR)) + # Each person's model amounts for the line 30 self-employment + # adjustments, in situation order. + adjustments = sum( + np.asarray(simulation.calculate(name, YEAR)) for name in PERSON_ADJUSTMENTS + ) + per_household, k = [], 0 + for h in households: + n = len(everyone(h)) + per_household.append(adjustments[k : k + n]) + k += n + return result, per_household + + +def amount(people, names): + return sum(p.get(name, 0) for p in people for name in names) + + +def by_line(lines, *wanted): + return [name for name, line in lines.items() if line in wanted] + + +def reference(h, person_adjustments): + """MI-1040CR line 33 from the form.""" + own = filers(h) + line_14 = amount(own, by_line(CLAIMANT_LINES, 14)) + line_15 = amount(own, by_line(CLAIMANT_LINES, 15)) + line_16 = max(0, amount(own, ["self_employment_income", "s_corp_income"])) + line_17 = max(0, amount(own, ["rental_income"])) + line_18 = amount(own, by_line(CLAIMANT_LINES, 18)) + line_19 = max(-CAPITAL_LOSS_LIMIT, amount(own, ["long_term_capital_gains"])) + lines_20_to_26 = amount(own, by_line(CLAIMANT_LINES, 20, 23, 24, 25, 26)) + received = amount(everyone(h), list(HOUSEHOLD_LINES)) + # Line 27: the household's Family Independence Program grant, in full. + received += h.get("tanf", 0) + # Line 30: the head and spouse come first in each household's slice. + line_30 = float(sum(person_adjustments[: len(own)])) + line_30 += amount(own, ["early_withdrawal_penalty"]) + line_31 = amount(everyone(h), [PREMIUMS]) + total = line_14 + line_15 + line_16 + line_17 + line_18 + line_19 + total += lines_20_to_26 + received + return max(0, total - line_30 - line_31) + + +def tolerance(h): + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + scale = sum(abs(v) for p in everyone(h) for v in p.values()) + scale += abs(h.get("tanf", 0)) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def copy(h): + out = {"head": dict(h["head"]), "tanf": h.get("tanf", 0)} + if h.get("spouse") is not None: + out["spouse"] = dict(h["spouse"]) + out["dependents"] = [dict(d) for d in h.get("dependents", [])] + return out + + +def without_dependents_own_amounts(h): + out = copy(h) + for d in out["dependents"]: + for name in CLAIMANT_LINES: + d[name] = 0 + return out + + +def with_household_amounts_on_head(h): + out = copy(h) + for d in out["dependents"]: + for name in [*HOUSEHOLD_LINES, PREMIUMS]: + out["head"][name] = out["head"].get(name, 0) + d.get(name, 0) + d[name] = 0 + return out + + +def assert_properties(households): + """Properties 1, 2 and 3. The modified copies go in the same + simulation.""" + zeroed = [without_dependents_own_amounts(h) for h in households] + moved = [with_household_amounts_on_head(h) for h in households] + result, adjustments = calculate(households + zeroed + moved) + n = len(households) + for i, h in enumerate(households): + tol = tolerance(h) + # 1. The form. + assert result[i] == pytest.approx(reference(h, adjustments[i]), abs=tol), h + assert result[i] >= 0, h + # 2. A dependent's own income and adjustments do not count. + assert result[n + i] == pytest.approx(result[i], abs=tol), h + # 3. Amounts received for the household count wherever recorded. + assert result[2 * n + i] == pytest.approx(result[i], abs=tol), h + + +# --------------------------------------------------------------------------- +# Deterministic tests (no Hypothesis needed). +# --------------------------------------------------------------------------- + + +def base_household(dependent_age=10, spouse=False): + head = zero_person() + head["employment_income_before_lsr"] = 40_000 + return { + "head": head, + "spouse": zero_person() if spouse else None, + "dependents": [dict(zero_person(), age=dependent_age)], + } + + +def test_each_source_for_a_dependent(): + """For a child and for a dependent parent, every source on its own: a + dependent's own income and adjustments leave household resources at the + base, and an amount received for the household adds in full, as it + would for the head.""" + households, cases = [], [] + for age, spouse in itertools.product([10, 75], [False, True]): + base = base_household(age, spouse) + households.append(base) + cases.append((age, spouse, None, 0)) + for name in PERSON_INPUTS: + values = [7_000, -7_000] if name in SIGNED else [7_000] + for value in values: + h = copy(base) + h["dependents"][0][name] = value + households.append(h) + cases.append((age, spouse, name, value)) + if name in HOUSEHOLD_LINES or name == PREMIUMS: + on_head = copy(base) + on_head["head"][name] = value + households.append(on_head) + cases.append((age, spouse, f"head:{name}", value)) + result, adjustments = calculate(households) + base_result = {} + for i, (age, spouse, name, value) in enumerate(cases): + if name is None: + base_result[(age, spouse)] = result[i] + head_result = { + (age, spouse, name[5:], value): result[i] + for i, (age, spouse, name, value) in enumerate(cases) + if name is not None and name.startswith("head:") + } + for i, (age, spouse, name, value) in enumerate(cases): + h = households[i] + assert result[i] == pytest.approx( + reference(h, adjustments[i]), abs=TOLERANCE + ), cases[i] + if name is None or name.startswith("head:"): + continue + base = base_result[(age, spouse)] + if name in CLAIMANT_LINES: + assert result[i] == pytest.approx(base, abs=TOLERANCE), cases[i] + elif name in HOUSEHOLD_LINES: + assert result[i] == pytest.approx(base + value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + else: # Premiums paid for the family. + assert result[i] == pytest.approx(base - value, abs=TOLERANCE) + assert result[i] == pytest.approx( + head_result[(age, spouse, name, value)], abs=TOLERANCE + ), cases[i] + + +def test_spouse_counts_like_the_head(): + """Both spouses' own amounts count: each source on the spouse adds what + it adds on the head.""" + households = [] + for name in CLAIMANT_LINES: + if name == "early_withdrawal_penalty": + continue + on_head = base_household(spouse=True) + on_head["head"][name] += 7_000 + on_spouse = base_household(spouse=True) + on_spouse["spouse"][name] = 7_000 + households += [on_head, on_spouse] + result, adjustments = calculate(households) + for k in range(0, len(households), 2): + assert result[k] == pytest.approx(result[k + 1], abs=TOLERANCE) + assert result[k] == pytest.approx( + reference(households[k], adjustments[k]), abs=TOLERANCE + ) + + +def reform_lists(household_resources=None, all_members=None): + """A reform that replaces Michigan's source lists for the year.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + p = parameters.gov.states.mi.tax.income + for node, values in [ + (p.household_resources, household_resources), + (p.household_resources_all_members, all_members), + ]: + if values is not None: + node.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +def test_reformed_lists(): + """6. A source moved into household_resources_all_members counts for + every member, and an SPM unit source put in household_resources in place + of tanf counts in full.""" + h = base_household() + h["dependents"][0]["gi_cash_assistance"] = 7_000 + baseline = Simulation(situation=build_situation([h])) + p = baseline.tax_benefit_system.parameters.gov.states.mi.tax.income + sources = list(p.household_resources(f"{YEAR}-01-01")) + all_members = list(p.household_resources_all_members(f"{YEAR}-01-01")) + # The dependent's own pilot payment is not household resources... + assert baseline.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 40_000, abs=TOLERANCE + ) + # ...unless a reform counts it for every member. + moved = Simulation( + situation=build_situation([h]), + reform=reform_lists(all_members=all_members + ["gi_cash_assistance"]), + ) + assert moved.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 47_000, abs=TOLERANCE + ) + # A parent with wages of 10,000 and a child, and an SPM unit grant of + # 6,000 given as tanf_if_takes_up, which the reform lists in place of tanf. + family = base_household() + family["head"]["employment_income_before_lsr"] = 10_000 + situation = build_situation([family]) + situation["spm_units"]["spm_unit_0"]["tanf_if_takes_up"] = {YEAR: 6_000} + swapped = Simulation( + situation=situation, + reform=reform_lists( + household_resources=[ + "tanf_if_takes_up" if s == "tanf" else s for s in sources + ], + all_members=[s for s in all_members if s != "tanf"], + ), + ) + assert swapped.calculate("mi_household_resources", YEAR)[0] == pytest.approx( + 16_000, abs=TOLERANCE + ) + + +# --------------------------------------------------------------------------- +# Hypothesis properties. Hypothesis is a dev extra: without it these tests +# are not collected and the deterministic tests above still run. +# --------------------------------------------------------------------------- + +try: + import hypothesis + import hypothesis.strategies as st +except ImportError: # pragma: no cover + hypothesis = None + +if hypothesis is not None: + SIGNED_AMOUNT = st.integers(min_value=-200_000, max_value=200_000) + AMOUNT = st.integers(min_value=100, max_value=100_000) + SLOW = [hypothesis.HealthCheck.too_slow] + + @st.composite + def person(draw): + p = zero_person() + # Each person draws a few sources, so households are sparse enough + # that the floor at zero does not hide everything. + names = draw(st.lists(st.sampled_from(PERSON_INPUTS), max_size=5, unique=True)) + for name in names: + p[name] = draw(SIGNED_AMOUNT if name in SIGNED else AMOUNT) + return p + + @st.composite + def household(draw): + dependents = [] + for _ in range(draw(st.integers(min_value=1, max_value=2))): + d = draw(person()) + d["age"] = draw(st.sampled_from([10, 16, 75])) + dependents.append(d) + return { + "head": draw(person()), + "spouse": draw(st.one_of(st.none(), person())), + "dependents": dependents, + "tanf": draw(st.one_of(st.just(0), AMOUNT)), + } + + @hypothesis.settings(max_examples=50, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=15)) + def test_properties(households): + assert_properties(households) + + @hypothesis.settings(max_examples=30, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=10), + st.sampled_from(list(HOUSEHOLD_LINES)), + st.integers(min_value=1, max_value=50_000), + ) + def test_more_received_for_a_dependent(households, source, extra): + """4. More of a line 21, 22 or 27 amount for a dependent raises + household resources by between 0 and the increase, and by exactly + the increase when they were positive.""" + # A household with positive resources, so the exact-increase branch + # always runs. + households = households + [base_household()] + raised = [] + for h in households: + r = copy(h) + r["dependents"][0][source] = r["dependents"][0].get(source, 0) + extra + raised.append(r) + result, _ = calculate(households + raised) + n = len(households) + for i, h in enumerate(raised): + tol = tolerance(h) + change = float(result[n + i]) - float(result[i]) + assert -tol <= change <= extra + tol, (households[i], source) + if result[i] > tol: + assert change == pytest.approx(extra, abs=tol), ( + households[i], + source, + ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py index 120ca814d65..28cb37c3abf 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/filer_loss_limited_net_capital_gains.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.irs.tax.federal_income.capital_gains.filer_schedule_d_lines import ( + filer_schedule_d_lines, +) class filer_loss_limited_net_capital_gains(Variable): @@ -13,7 +16,11 @@ class filer_loss_limited_net_capital_gains(Variable): "13), with a net loss limited under 26 USC 1211(b). This is Form 8960 " "line 5a without Schedule 1 line 4. A tax unit dependent's gains and " "losses are on the dependent's own return. Unlike " - "loss_limited_net_capital_gains, this leaves dependents out." + "loss_limited_net_capital_gains, this leaves dependents out. It " + "uses the same filer Schedule D lines as the tax worksheet. A tax " + "unit net_capital_gains amount supplied as an input " + "is kept; that amount is read as covering every member, and any " + "dependent's person-level gains and losses are then taken out." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/1211#b", @@ -25,15 +32,5 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.irs filing_status = tax_unit("filing_status", period) loss_limit = p.capital_gains.loss_limit[filing_status] - net_capital_gains = tax_unit_non_dep_add( - tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] - ) - # Capital gain distributions go on Schedule D line 13 with the other - # gains and losses, so they net before the limit. Each filer's input - # is floored at zero, as in irs_gross_income. - person = tax_unit.members - not_dependent = ~person("is_tax_unit_dependent", period) - distributions = tax_unit.sum( - not_dependent * max_(0, person("non_sch_d_capital_gains", period)) - ) - return max_(-loss_limit, net_capital_gains + distributions) + lines = filer_schedule_d_lines(tax_unit, period) + return max_(-loss_limit, lines.line_16) diff --git a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py index a0e3654534f..7f15bcc6666 100644 --- a/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py +++ b/policyengine_us/variables/gov/irs/tax/federal_income/capital_gains/filer_schedule_d_lines.py @@ -9,8 +9,9 @@ def filer_schedule_d_lines(tax_unit, period): """Schedule D (Form 1040) lines 7, 15 and 16 of the head and spouse, shared - by the Form 1040 line 16 routing (has_qdiv_or_ltcg), net_capital_gain and - the Schedule D Tax Worksheet. + by Form 1040 line 7a (filer_loss_limited_net_capital_gains), the line 16 + routing (has_qdiv_or_ltcg), net_capital_gain and the Schedule D Tax + Worksheet. Line 7 is the net short-term capital gain or loss. Line 15 is the net long-term capital gain or loss, including capital gain distributions @@ -19,12 +20,16 @@ def filer_schedule_d_lines(tax_unit, period): A tax unit dependent's gains, losses and distributions belong on the dependent's own return, so they are left out, as irs_gross_income leaves - them out of adjusted gross income. Line 16 starts from net_capital_gains, - so a tax unit amount supplied there is kept; that amount is read as - covering every member, and any dependent's person-level gains and losses - are then taken out. An explicit aggregate override can make line 16 - differ from lines 7 and 15 combined, and the worksheet and statutory - net-capital-gain paths can then disagree. + them out of adjusted gross income. Line 16 combines the filers' lines 7 + and 15 directly, so a large dependent's gain cannot round away a filer's + smaller gain in the float32 tax unit aggregate. When net_capital_gains + differs from the computed all-member aggregate in its storage precision, + that supplied amount covers every member, so dependents' own gains and + losses are removed before the filers' distributions are added. An amount + equal to the computed aggregate uses the direct filer components. + An explicit aggregate override can make line 16 differ from lines 7 and + 15 combined, and the worksheet and statutory net-capital-gain paths can + then disagree. Capital gain distributions reported without Schedule D (non_sch_d_capital_gains) are long-term capital gains (26 U.S.C. @@ -43,14 +48,20 @@ def filer_schedule_d_lines(tax_unit, period): tax_unit_non_dep_add(tax_unit, period, ["long_term_capital_gains"]) + distributions ) - # Line 16: lines 7 and 15 combined, figured from net_capital_gains so a - # supplied tax unit amount is kept. + # A component-derived aggregate may have rounded away a filer's dollar, + # so sum the filers directly when it matches its normal stored value. + # A differing supplied aggregate covers every member: subtract the + # dependents directly instead of reconciling it through a rounded total. + net_capital_gains = tax_unit("net_capital_gains", period) + computed_gains = add( + tax_unit, period, ["long_term_capital_gains", "short_term_capital_gains"] + ).astype(net_capital_gains.dtype) dependent_gains = tax_unit.sum( - dependent - * ( - person("long_term_capital_gains", period) - + person("short_term_capital_gains", period) - ) + dependent * person("long_term_capital_gains", period) + ) + tax_unit.sum(dependent * person("short_term_capital_gains", period)) + line_16 = where( + net_capital_gains == computed_gains, + line_7 + line_15, + net_capital_gains.astype(float) - dependent_gains + distributions, ) - line_16 = tax_unit("net_capital_gains", period) - dependent_gains + distributions return FilerScheduleDLines(line_7=line_7, line_15=line_15, line_16=line_16) diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 8380194837e..103dffc9cdc 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -13,15 +13,28 @@ class mi_household_resources(Variable): "statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/" "section-206-508/", "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-508", + # MCL 206.510(1): "Income", including premiums paid for the family. + "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-206-510", "https://web.archive.org/web/20250202150154/https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/BOOK_MI-1040CR-7.pdf", + # 2025 MI-1040CR-7 book (archived; michigan.gov removed it): + # "Total Household Resources" (page 4) and lines 17 to 23 (page 8) + # and 24 to 33 (page 9). They match MI-1040CR lines 14 to 31 on whose + # income counts: CR-7 lines 24, 25 and 30 are MI-1040CR lines 21, 22 + # and 27. + "https://web.archive.org/web/20260218211009/https://www.michigan.gov/" + "taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/" + "MI-1040CR-7-Book.pdf?rev=e4ba4fedc63942e48442037517312d4a" + "&hash=AF396E577B1932DF55BFD7F22D7EF06A#page=8", # 2025 MI-1040 book: "Total Household Resources" (page 26) and - # MI-1040CR lines 16 and 17 (page 31), 19 and 30 (page 32). + # MI-1040CR lines 14 to 17 (page 31) and 18 to 31 (pages 32 and 33). "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=26", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=31", "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" "Forms/IIT/TY2025/MI-1040-Book.pdf#page=32", + "https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/" + "Forms/IIT/TY2025/MI-1040-Book.pdf#page=33", ) def formula(tax_unit, period, parameters): @@ -47,22 +60,38 @@ def formula(tax_unit, period, parameters): "farm_rent_income", } - # MCL 206.508(3): "'Household' means a claimant and spouse." A - # dependent's business and rental items belong on the dependent's own - # return, so they neither add to nor net against lines 16 and 17, as - # loss_ald leaves them off this return. tax_unit_non_dep_add sums a - # person-level source over the head and spouse and takes a - # tax-unit-level one (other_net_gain) as is. + # MCL 206.508(3): "'Household' means a claimant and spouse", and + # (4) counts "all income received by all persons of a household". + # The MI-1040CR says "Include all taxable and nontaxable income you + # and your spouse received" (2025 MI-1040 book, page 31). A + # dependent's own income belongs on the dependent's return, so a + # source is summed over the head and spouse only, as in + # irs_gross_income. A tax-unit-level source (other_net_gain, + # filer_loss_limited_net_capital_gains) already describes the + # filer's return. + # The sources in household_resources_all_members are amounts the + # claimant receives for others in the household (lines 21, 22 and + # 27), so they are summed over every member. So is a source defined + # for a larger group than the tax unit, such as tanf for the SPM + # unit: add gives the tax unit its members' shares. + all_members = p.household_resources_all_members + business_income = 0 rental_income = 0 other_income = 0 for source in p.household_resources: + entity = tax_unit.entity.get_variable(source).entity + own_return = entity.is_person or entity.key == tax_unit.entity.key + if source in all_members or not own_return: + amount = add(tax_unit, period, [source]) + else: + amount = tax_unit_non_dep_add(tax_unit, period, [source]) if source in business_sources: - business_income += tax_unit_non_dep_add(tax_unit, period, [source]) + business_income += amount elif source in rental_sources: - rental_income += tax_unit_non_dep_add(tax_unit, period, [source]) + rental_income += amount else: - other_income += add(tax_unit, period, [source]) + other_income += amount total = other_income + max_(business_income, 0) + max_(rental_income, 0) # Line 30: "Enter total adjustments from your U.S. Form 1040, @@ -85,7 +114,8 @@ def formula(tax_unit, period, parameters): # The Schedule 1 is the claimant's own (MCL 206.508(3)): a person-level # adjustment, such as a dependent's IRA contribution or early withdrawal # penalty, is summed over the head and spouse, as above_the_line_deductions - # does. A tax-unit-level adjustment already describes the filer's return. + # does. A tax-unit-level adjustment already describes the filer's return, + # including educator_expense_ald, capped per eligible educator. ald = parameters(period).gov.irs.ald adjustments = tax_unit_non_dep_add( tax_unit, @@ -95,8 +125,11 @@ def formula(tax_unit, period, parameters): for deduction in p.household_resources_adjustments if deduction in ald.deductions ], - include_dependents=ald.filer_amounts_recorded_on_dependents, ) - # Line 31: health insurance premiums. + # Line 31: "insurance premiums you paid for yourself and your + # family". MCL 206.510(1) lets a person deduct "the amount that + # person paid in premiums ... for that insurance plan for the + # person's family". A premium on any member's record is read as one + # the claimant or spouse paid for the family's coverage. health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) return max_(0, total - adjustments - health_insurance_premiums)