diff --git a/changelog.d/mi-household-resources-business-losses.fixed.md b/changelog.d/mi-household-resources-business-losses.fixed.md new file mode 100644 index 00000000000..7e9b0108f23 --- /dev/null +++ b/changelog.d/mi-household-resources-business-losses.fixed.md @@ -0,0 +1 @@ +Michigan household resources net business and rental income within MI-1040CR lines 16 and 17 before flooring each total at zero, count only the claimant's and spouse's business and rental items on those two lines (MCL 206.508(3)), count estate and trust income and Form 4797 ordinary gains on line 16, and no longer subtract business, rental or capital losses again as Schedule 1 adjustments on line 30. 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 4a762949c46..d92aee43d73 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 @@ -5,9 +5,17 @@ values: - strike_benefits - dividend_income - interest_income + # MI-1040CR line 16, net business income: U.S. Schedule C, Form 4797 + # Part II, Schedule E Parts II and III, and Schedule F, netted and + # floored at zero as a total. - farm_operations_income - total_self_employment_income - partnership_s_corp_income + - estate_income + - other_net_gain + # MI-1040CR line 17, net royalty or rent income: U.S. Schedule E Parts I, + # IV (REMIC income, which has no input) and V, netted and floored at zero + # as a total. - rental_income - farm_rent_income - pension_income @@ -45,5 +53,7 @@ metadata: href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2024/MI-1040-Instructions.pdf#page=31 - title: Michigan 2025 Individual Income Tax Instructions href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=31 + - title: Michigan 2025 Individual Income Tax Instructions, Total Household Resources + href: https://www.michigan.gov/taxes/-/media/Project/Websites/taxes/Forms/IIT/TY2025/MI-1040-Book.pdf#page=26 - title: Michigan Compiled Laws Chapter 9 Section 206.508 - Definitions. (4) href: https://law.justia.com/codes/michigan/2022/chapter-206/statute-act-281-of-1967/division-281-1967-1/division-281-1967-1-9/section-206-508/ 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 629963b3f8d..a0ed4fad654 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 @@ -1,13 +1,27 @@ +# Line numbers are the MI-1040CR's (2024 and 2025 MI-1040 books, pages 31-32; +# the two years' household resources lines are the same). Total household +# resources are "AGI, excluding net business and farm losses, net rent and +# royalty losses, and any carryover of a net operating loss, plus all income +# exempt or excluded from AGI" (page 26), as in MCL 206.508(4). +# Line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III +# and Schedule F; line 17 nets Schedule E Parts I, IV (REMICs, no input) and V. +# "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. + - name: Cannot be negative period: 2024 input: net_capital_gains: -2_000 state_code: MI health_insurance_premiums: 0 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # Capital gains losses are limited to -2,000 (within $3,000 limit) - # but mi_household_resources cannot be negative, so 0 + # Line 19: -2,000 (within the $3,000 loss limit). + # Line 30: penalty on early withdrawal of savings (Schedule 1 line 18), + # 1,000. + # -2,000 - 1,000 = -3,000; household resources are not negative, so 0. mi_household_resources: 0 - name: Reduced to 0 @@ -16,9 +30,10 @@ net_capital_gains: 2_000 state_code: MI health_insurance_premiums: 3_000 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # 2,000 - 3,000 - 1,000 = -2,000, floored at 0 + # Line 19: 2,000. Line 30: 1,000. Line 31: 3,000. + # 2,000 - 1,000 - 3,000 = -2,000, floored at 0. mi_household_resources: 0 - name: Partially reduced @@ -27,9 +42,10 @@ net_capital_gains: 10_000 state_code: MI health_insurance_premiums: 3_000 - above_the_line_deductions: 1_000 + early_withdrawal_penalty: 1_000 output: - # 10,000 - 3,000 - 1,000 = 6,000 + # Line 19: 10,000. Line 30: 1,000. Line 31: 3,000. + # Line 33: 10,000 - (1,000 + 3,000) = 6,000. mi_household_resources: 6_000 - name: Negative business income floored at 0 @@ -39,11 +55,11 @@ irs_employment_income: 100_000 state_code: MI output: - # Per MI-1040CR-7 Line 21: "If negative, enter 0" - # Self-employment income floored at 0 = 0 - # But loss_ald = 50,000 (federal above-the-line deduction for business loss) - # Total: 100,000 - 50,000 = 50,000 - mi_household_resources: 50_000 + # Line 14: 100,000. Line 16: Schedule C -50,000; the total is negative, + # so 0. The business loss is not a Schedule 1 adjustment, so line 30 is + # 0 (no self-employment tax on a loss). + # Line 33: 100,000. + mi_household_resources: 100_000 - name: Negative SSTB business income floored at 0 period: 2024 @@ -52,16 +68,22 @@ irs_employment_income: 100_000 state_code: MI output: - mi_household_resources: 50_000 + # Line 14: 100,000. Line 16: Schedule C -50,000, so 0. Line 30: 0. + # Line 33: 100,000. + mi_household_resources: 100_000 - name: Farm operations income counted as household resources period: 2024 input: farm_operations_income: 50_000 - above_the_line_deductions: 0 state_code: MI output: - mi_household_resources: 50_000 + # Line 16: Schedule F 50,000. + # Line 30: deduction for self-employment tax (Schedule 1 line 15), half + # of 50,000 x 0.9235 x 15.3% = 3,532.39. + # Line 33: 50,000 - 3,532.39 = 46,467.61. + mi_household_resources: 46_467.61 + absolute_error_margin: 0.01 - name: Negative rental income floored at 0 period: 2024 @@ -70,11 +92,11 @@ employment_income_before_lsr: 50_000 state_code: MI output: - # Per MI-1040CR-7 Line 22: "If negative, enter 0" - # Rental income floored at 0 for the MI source line, - # but federal above_the_line_deductions includes loss_ald of 30,000. - # Total: 50,000 - 30,000 = 20,000 - mi_household_resources: 20_000 + # Line 14: 50,000. Line 17: Schedule E Part I -30,000; the total is + # negative, so 0. The rental loss is not a Schedule 1 adjustment, so + # line 30 is 0. + # Line 33: 50,000. + mi_household_resources: 50_000 - name: Large capital gains loss limited to 3000 period: 2024 @@ -85,11 +107,10 @@ state_code: MI filing_status: JOINT output: - # Per MI-1040CR-7 Line 24: losses cannot exceed $3,000 for MFJ - # loss_limited_net_capital_gains: max(-150,000, -3,000) = -3,000 - # The capital loss is not a U.S. Schedule 1 adjustment (MI-1040CR line - # 30), so it is not subtracted a second time. - # Total: 50,000 + (-3,000) = 47,000 + # Line 19: "losses cannot exceed $3,000 if single or married filing + # jointly": max(-150,000, -3,000) = -3,000. + # The capital loss is not a Schedule 1 adjustment, so line 30 is 0. + # Line 33: 50,000 - 3,000 = 47,000. mi_household_resources: 47_000 - name: Capital gains loss limited to 1500 for MFS @@ -100,10 +121,9 @@ state_code: MI filing_status: SEPARATE output: - # Per MI-1040CR-7 Line 24: losses cannot exceed $1,500 for MFS - # loss_limited_net_capital_gains: max(-100,000, -1,500) = -1,500 - # Not subtracted again as a Schedule 1 adjustment (line 30). - # Total: 50,000 + (-1,500) = 48,500 + # Line 19: "$1,500 if married filing separately": max(-100,000, -1,500) + # = -1,500. Line 30: 0. + # Line 33: 50,000 - 1,500 = 48,500. mi_household_resources: 48_500 - name: Capital gain distributions net against a capital loss @@ -123,9 +143,8 @@ members: [person1] state_code: MI output: - # 2025 MI-1040 book, MI-1040CR line 19: "net capital gains and losses ... - # from your U.S. Schedule D". Schedule D line 16 = -20,000 + 20,000 - # (line 13) = 0. Line 30 (U.S. Schedule 1 adjustments) has none. + # Line 19: "net capital gains and losses ... from your U.S. Schedule D". + # Schedule D line 16 = -20,000 + 20,000 (line 13) = 0. Line 30: 0. mi_household_resources: 50_000 - name: Spouses' capital gains and losses net on a joint Schedule D @@ -152,3 +171,297 @@ output: # Line 19: Schedule D line 16 = 40,000 - 40,000 = 0. mi_household_resources: 50_000 + +- name: A Schedule C loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -10_000 + s_corp_income: 30_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 14: 50,000. + # Line 16: Schedule C -10,000 + Schedule E Part II 30,000 = 20,000. + # Line 30: 0 (no self-employment tax: the Schedule C result is a loss, + # and S corporation income is not self-employment income). + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: A net business loss on line 16 is floored at 0 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -40_000 + s_corp_income: 30_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: -40,000 + 30,000 = -10,000; "If the total is negative enter + # 0." The net business loss is excluded (MCL 206.508(4)(a)), not + # subtracted on line 30. + # Line 33: 50,000. + mi_household_resources: 50_000 + +- name: A business loss does not reduce rental income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + self_employment_income: -20_000 + rental_income: 15_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule C -20,000, so 0. Line 17: Schedule E Part I 15,000. + # The two lines are floored separately. Line 30: 0. + # Line 33: 50,000 + 0 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A rental loss nets against farm rental income on line 17 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + rental_income: -10_000 + farm_rent_income: 25_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 17: Schedule E Part I -10,000 + Part V (Form 4835) 25,000 = + # 15,000. Line 30: 0. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: A rental loss does not reduce business income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + rental_income: -30_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: 20,000. Line 17: -30,000, so 0. Line 30: 0. + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: Estate and trust income is business income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + estate_income: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule E Part III 12,000. Line 30: 0. + # Line 33: 50,000 + 12,000 = 62,000. + mi_household_resources: 62_000 + +- name: An estate loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + estate_income: -5_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Schedule E Part III -5,000 + Part II 20,000 = 15,000. + # Line 30: 0. + # Line 33: 50,000 + 15,000 = 65,000. + mi_household_resources: 65_000 + +- name: Form 4797 ordinary gains are business income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + tax_units: + tax_unit: + members: [person1] + other_net_gain: 6_000 + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Form 4797 Part II 6,000. Line 30: 0. + # Line 33: 50,000 + 6,000 = 56,000. + mi_household_resources: 56_000 + +- name: A Form 4797 ordinary loss nets against S corporation income on line 16 + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + s_corp_income: 20_000 + tax_units: + tax_unit: + members: [person1] + other_net_gain: -8_000 + households: + household: + members: [person1] + state_code: MI + output: + # Line 16: Form 4797 Part II -8,000 + Schedule E Part II 20,000 = 12,000. + # Line 30: 0. + # Line 33: 50,000 + 12,000 = 62,000. + mi_household_resources: 62_000 + +- name: Schedule 1 adjustments still reduce household resources with a business loss + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 60_000 + self_employment_income: 10_000 + farm_operations_income: -4_000 + early_withdrawal_penalty: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MI + output: + # Line 14: 60,000. + # Line 16: Schedule C 10,000 + Schedule F -4,000 = 6,000. + # Line 30: deduction for self-employment tax (Schedule 1 line 15), half + # of 6,000 x 0.9235 x 15.3% = 423.89, plus the early withdrawal penalty + # (line 18), 500: 923.89. + # Line 33: 60,000 + 6,000 - 923.89 = 65,076.11. + mi_household_resources: 65_076.11 + absolute_error_margin: 0.01 + +- name: A dependent's Schedule C loss does not net against the claimant's business income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + s_corp_income: 20_000 + person2: + age: 15 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # The household is the claimant (MCL 206.508(3)); the dependent's + # Schedule C belongs on the dependent's own return. + # Line 16: Schedule E Part II 20,000. Line 30: 0. + # Line 33: 50,000 + 20,000 = 70,000. + mi_household_resources: 70_000 + +- name: A dependent's rental loss does not net against the claimant's farm rental income + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + farm_rent_income: 10_000 + person2: + age: 15 + rental_income: -10_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MI + output: + # Line 17: the claimant's Schedule E Part V 10,000. Line 30: 0. + # Line 33: 50,000 + 10,000 = 60,000. + mi_household_resources: 60_000 + +- name: A dependent's S corporation income is not household resources + period: 2025 + input: + people: + person1: + age: 45 + employment_income_before_lsr: 50_000 + person2: + age: 15 + s_corp_income: 8_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 + # dependent's Schedule E income is not on line 16. Line 30: 0. + # Line 33: 50,000. + mi_household_resources: 50_000 diff --git a/policyengine_us/tests/test_mi_household_resources_losses.py b/policyengine_us/tests/test_mi_household_resources_losses.py new file mode 100644 index 00000000000..d72a0d40574 --- /dev/null +++ b/policyengine_us/tests/test_mi_household_resources_losses.py @@ -0,0 +1,437 @@ +"""Property tests: business and rental losses in Michigan household resources. + +MCL 206.508(4) defines total household resources as income "increased by the +following deductions from federal gross income: (a) Any net business loss +after netting all business income and loss. (b) Any net rental or royalty +loss." The 2025 MI-1040 book (page 26) puts it as "AGI, excluding net business +and farm losses, net rent and royalty losses". On the MI-1040CR: + +- line 16 nets U.S. Schedule C, Form 4797 Part II, Schedule E Parts II and III + and Schedule F, and line 17 nets Schedule E Parts I, IV (REMIC income, which + has no input) and V; "If the total is negative enter 0"; +- line 19 is net capital gains and losses from Schedule D, a loss limited to + $3,000; +- line 30 is "total adjustments from your U.S. Form 1040, Schedule 1" + (Part II). Business, rental and capital losses are income items, not + adjustments, so loss_ald does not enter it; +- line 31 is health insurance premiums. + +The household is "a claimant and spouse" (MCL 206.508(3)). + +So for every combination of business, rental and capital gains and losses: + +1. mi_household_resources = max(0, wages + max(0, line 16 sources) + + max(0, line 17 sources) + max(-3,000, capital gains) - Schedule 1 + adjustments - premiums), where the adjustments here are the deduction for + self-employment tax and the early withdrawal penalty. +2. It does not depend on loss_ald: a reform that takes loss_ald out of + gov.irs.ald.deductions changes AGI but not household resources (checked on + the grid, which builds the reform once). +3. Losses stay in their own line. When every line 16 source is zero or + negative, the result equals the result with them all set to zero; the + same holds for line 17. +4. Household resources are between zero and the sum of the positive income + amounts. +5. Raising a source that carries no self-employment tax (S corporation, + estate, Form 4797, rental or farm rental income) by d raises household + resources by between 0 and d. Schedule C and Schedule F income are left + 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. + +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 +comparison allows one cent or eight float32 spacings at the sum of the +household's absolute amounts, whichever is larger: $0.25 for a household whose +amounts add up to $500,000, and up to about $4 for the largest two-person +households drawn. The early withdrawal penalty and premiums are drawn as zero +or at least $100, so leaving either out would exceed the tolerance. +""" + +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 + +PERSON_LINE_16 = [ + "self_employment_income", + "farm_operations_income", + "s_corp_income", + "estate_income", +] +PERSON_LINE_17 = ["rental_income", "farm_rent_income"] +PERSON_INPUTS = [ + "employment_income_before_lsr", + *PERSON_LINE_16, + *PERSON_LINE_17, + "long_term_capital_gains", + "early_withdrawal_penalty", + "health_insurance_premiums", +] +# Form 4797 Part II, a tax unit input. +UNIT_LINE_16 = ["other_net_gain"] +# Sources without self-employment tax, for property 5. +NO_SE_TAX_SOURCES = ["s_corp_income", "estate_income", "rental_income"] +NO_SE_TAX_SOURCES += ["farm_rent_income", "other_net_gain"] + + +def zero_person(): + return {name: 0 for name in PERSON_INPUTS} + + +def build_situation(households): + """One Michigan tax unit per household: one adult (single) or two + (joint), any dependents (aged 15), and the tax unit's Form 4797 amount.""" + people, tax_units, marital_units, households_out = {}, {}, {}, {} + for i, h in enumerate(households): + adults, members = [], [] + dependents = h.get("dependents", []) + for j, (p, age) in enumerate( + [(p, 45) for p in h["people"]] + [(p, 15) for p in dependents] + ): + name = f"person_{i}_{j}" + people[name] = {"age": {YEAR: age}} + for variable in PERSON_INPUTS: + people[name][variable] = {YEAR: p[variable]} + members.append(name) + if age == 45: + adults.append(name) + else: + marital_units[f"marital_unit_{i}_{j}"] = {"members": [name]} + # Set on every unit: a variable input for some units gives the + # others its default value, not its formula. + tax_units[f"tax_unit_{i}"] = { + "members": members, + "other_net_gain": {YEAR: h["other_net_gain"]}, + } + marital_units[f"marital_unit_{i}"] = {"members": adults} + households_out[f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "MI"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households_out, + } + + +def without_loss_ald(): + """A reform that removes loss_ald from gov.irs.ald.deductions.""" + + class reform(Reform): + def apply(self): + def modify(parameters): + deductions = parameters.gov.irs.ald.deductions + values = [d for d in deductions(f"{YEAR}-01-01") if d != "loss_ald"] + deductions.update( + start=instant(f"{YEAR}-01-01"), + stop=instant(f"{YEAR}-12-31"), + value=values, + ) + return parameters + + self.modify_parameters(modify) + + return reform + + +OUTPUTS = [ + "mi_household_resources", + "self_employment_tax_ald", + "adjusted_gross_income", +] + + +def calculate(households, reform=None): + simulation = Simulation(situation=build_situation(households), reform=reform) + results = {v: np.asarray(simulation.calculate(v, YEAR)) for v in OUTPUTS} + # Line 30 summed directly, as the formula does: subtracting a large + # loss_ald from above_the_line_deductions would lose cents in float32. + deductions = simulation.tax_benefit_system.parameters.gov.irs.ald.deductions + results["schedule_1_adjustments"] = sum( + np.asarray(simulation.calculate(d, YEAR, map_to="tax_unit")) + for d in deductions(f"{YEAR}-01-01") + if d != "loss_ald" + ) + return results + + +def tolerance(h): + """One cent, or eight float32 spacings at the sum of the household's + absolute amounts.""" + people = h["people"] + h.get("dependents", []) + scale = sum(abs(v) for p in people for v in p.values()) + scale += abs(h["other_net_gain"]) + return max(TOLERANCE, 8 * float(np.spacing(np.float32(scale)))) + + +def total(h, names): + return sum(p[name] for p in h["people"] for name in names) + + +def reference(h, self_employment_tax_ald): + """MI-1040CR line 33, total household resources, from the form, for a + household without dependents.""" + line_14 = total(h, ["employment_income_before_lsr"]) + line_16 = max(0, total(h, PERSON_LINE_16) + h["other_net_gain"]) + line_17 = max(0, total(h, PERSON_LINE_17)) + line_19 = max(-CAPITAL_LOSS_LIMIT, total(h, ["long_term_capital_gains"])) + line_30 = self_employment_tax_ald + total(h, ["early_withdrawal_penalty"]) + line_31 = total(h, ["health_insurance_premiums"]) + return max(0, line_14 + line_16 + line_17 + line_19 - line_30 - line_31) + + +def positive_income(h): + names = ["employment_income_before_lsr", *PERSON_LINE_16, *PERSON_LINE_17] + names.append("long_term_capital_gains") + return sum(max(0, p[name]) for p in h["people"] for name in names) + max( + 0, h["other_net_gain"] + ) + + +def with_sources_zeroed(h, person_sources, unit_sources): + out = { + "people": [dict(p) for p in h["people"]], + "other_net_gain": h["other_net_gain"], + } + for p in out["people"]: + for name in person_sources: + p[name] = 0 + if "other_net_gain" in unit_sources: + out["other_net_gain"] = 0 + return out + + +def only_losses(h, person_sources, unit_sources): + return all(p[name] <= 0 for p in h["people"] for name in person_sources) and all( + h[name] <= 0 for name in unit_sources + ) + + +def assert_properties(households, check_loss_ald_reform=False): + """Properties 1, 3 and 4 (and 2 when asked) for households without + dependents. The zeroed copies for property 3 go in the same simulation.""" + zeroed, zeroed_of = [], [] + for i, h in enumerate(households): + for person_sources, unit_sources in ( + (PERSON_LINE_16, UNIT_LINE_16), + (PERSON_LINE_17, []), + ): + if only_losses(h, person_sources, unit_sources): + zeroed.append(with_sources_zeroed(h, person_sources, unit_sources)) + zeroed_of.append(i) + model = calculate(households + zeroed) + n = len(households) + for i, h in enumerate(households): + result = model["mi_household_resources"][i] + tol = tolerance(h) + # 1. The form. + assert result == pytest.approx( + reference(h, model["self_employment_tax_ald"][i]), abs=tol + ), h + # The only Schedule 1 adjustments in these households are the two + # the reference uses. + expected_adjustments = model["self_employment_tax_ald"][i] + total( + h, ["early_withdrawal_penalty"] + ) + assert model["schedule_1_adjustments"][i] == pytest.approx( + expected_adjustments, abs=tol + ), h + # 4. Bounds. + assert 0 <= result <= positive_income(h) + tol, h + # 3. Losses stay in their own line. + for k, i in enumerate(zeroed_of): + assert model["mi_household_resources"][i] == pytest.approx( + model["mi_household_resources"][n + k], abs=tolerance(households[i]) + ), households[i] + # 2. Not loss_ald. + if check_loss_ald_reform: + no_loss_ald = calculate(households, reform=without_loss_ald()) + for i, h in enumerate(households): + assert no_loss_ald["mi_household_resources"][i] == pytest.approx( + model["mi_household_resources"][i], abs=tolerance(h) + ), h + + +# --------------------------------------------------------------------------- +# Deterministic tests (no Hypothesis needed). +# --------------------------------------------------------------------------- + +GRID_VALUES = [-30_000, 0, 20_000] +GRID_SOURCES = [*PERSON_LINE_16, *PERSON_LINE_17, "other_net_gain"] + + +def grid_households(): + """Every sign combination of the seven line 16 and 17 sources.""" + households = [] + for values in itertools.product(GRID_VALUES, repeat=len(GRID_SOURCES)): + person = zero_person() + person["employment_income_before_lsr"] = 50_000 + amounts = dict(zip(GRID_SOURCES, values)) + for name in [*PERSON_LINE_16, *PERSON_LINE_17]: + person[name] = amounts[name] + households.append( + {"people": [person], "other_net_gain": amounts["other_net_gain"]} + ) + return households + + +def test_grid(): + assert_properties(grid_households(), check_loss_ald_reform=True) + + +def test_worked_example(): + """A Schedule C loss of 10,000 and S corporation income of 30,000 give + line 16 = 20,000; a rental loss of 30,000 gives line 17 = 0; the business + loss is not subtracted again on line 30.""" + person = zero_person() + person.update( + employment_income_before_lsr=50_000, + self_employment_income=-10_000, + s_corp_income=30_000, + rental_income=-30_000, + ) + model = calculate([{"people": [person], "other_net_gain": 0}]) + assert model["mi_household_resources"][0] == 70_000 + # Federal AGI deducts both losses: 50,000 + 30,000 - 10,000 - 30,000. + assert model["adjusted_gross_income"][0] == 40_000 + + +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], + "s_corp_income": [-20_000, 0, 15_000], + "estate_income": [-5_000, 0, 5_000], + "rental_income": [-20_000, 0, 15_000], + "farm_rent_income": [-5_000, 0, 5_000], +} + + +def test_dependents_business_and_rental_items(): + """6. Households whose claimant has business and rental income and + losses, with a dependent whose line 16 and 17 amounts take every + combination below, equal the same households without the dependent's + amounts.""" + claimants = [] + # Both lines positive, line 16 negative, line 17 negative. + for s_corp, rent in [(20_000, 15_000), (-30_000, 20_000), (20_000, -30_000)]: + claimant = zero_person() + claimant.update( + employment_income_before_lsr=50_000, + s_corp_income=s_corp, + rental_income=rent, + ) + claimants.append(claimant) + with_dependent, without = [], [] + names = list(DEPENDENT_VALUES) + for claimant in claimants: + for values in itertools.product(*DEPENDENT_VALUES.values()): + dependent = zero_person() + dependent.update(dict(zip(names, values))) + with_dependent.append( + {"people": [claimant], "dependents": [dependent], "other_net_gain": 0} + ) + without.append( + { + "people": [claimant], + "dependents": [zero_person()], + "other_net_gain": 0, + } + ) + model = calculate(with_dependent + without) + n = len(with_dependent) + result = model["mi_household_resources"] + np.testing.assert_allclose(result[:n], result[n:], atol=TOLERANCE) + # The claimant's own lines still net and floor. + for i, h in enumerate(without): + assert result[n + i] == pytest.approx( + reference(h, model["self_employment_tax_ald"][n + i]), abs=TOLERANCE + ), h + + +# --------------------------------------------------------------------------- +# Hypothesis properties. Hypothesis is a dev extra: without it these two +# 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: + AMOUNT = st.integers(min_value=-500_000, max_value=500_000) + SMALL = st.integers(min_value=-20_000, max_value=20_000) + NONNEGATIVE = st.integers(min_value=0, max_value=200_000) + # Each example builds Simulations; on a loaded runner input generation + # can trip the too_slow health check, which says nothing about the model. + SLOW = [hypothesis.HealthCheck.too_slow] + + @st.composite + def person(draw): + amounts = draw(st.sampled_from([AMOUNT, SMALL])) + p = zero_person() + p["employment_income_before_lsr"] = draw(st.one_of(st.just(0), NONNEGATIVE)) + for name in [*PERSON_LINE_16, *PERSON_LINE_17, "long_term_capital_gains"]: + p[name] = draw(st.one_of(st.just(0), amounts)) + p["early_withdrawal_penalty"] = draw( + st.one_of(st.just(0), st.integers(min_value=100, max_value=5_000)) + ) + p["health_insurance_premiums"] = draw( + st.one_of(st.just(0), st.integers(min_value=100, max_value=10_000)) + ) + return p + + @st.composite + def household(draw): + n = draw(st.sampled_from([1, 2])) + return { + "people": [draw(person()) for _ in range(n)], + "other_net_gain": draw(st.one_of(st.just(0), AMOUNT, SMALL)), + } + + @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.given(st.lists(household(), min_size=1, max_size=20)) + def test_properties(households): + assert_properties(households) + + @hypothesis.settings(max_examples=25, deadline=None, suppress_health_check=SLOW) + @hypothesis.given( + st.lists(household(), min_size=1, max_size=10), + st.sampled_from(NO_SE_TAX_SOURCES), + st.integers(min_value=1, max_value=100_000), + ) + def test_more_income_without_self_employment_tax(households, source, extra): + """5. More S corporation, estate, Form 4797, rental or farm rental + income raises household resources by between 0 and the increase.""" + raised = [] + for h in households: + r = {"people": [dict(p) for p in h["people"]]} + r["other_net_gain"] = h["other_net_gain"] + if source == "other_net_gain": + r["other_net_gain"] += extra + else: + r["people"][0][source] += extra + raised.append(r) + result = calculate(households + raised)["mi_household_resources"] + n = len(households) + for i, h in enumerate(raised): + change = float(result[n + i]) - float(result[i]) + tol = tolerance(h) + assert -tol <= change <= extra + tol, (households[i], source, extra) 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 7bb62ea0b73..5a2b4d4e27a 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 @@ -12,52 +12,73 @@ class mi_household_resources(Variable): "https://law.justia.com/codes/michigan/2022/chapter-206/" "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", "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-1040 book, MI-1040CR lines 19 (capital gains) and 30 - # (adjustments from U.S. Schedule 1). + # 2025 MI-1040 book: "Total Household Resources" (page 26) and + # MI-1040CR lines 16 and 17 (page 31), 19 and 30 (page 32). + "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", ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.mi.tax.income - # Per form instructions, only certain income sources must be - # floored at 0 if negative. Capital gains have special loss limitation. - income_sources = p.household_resources - - # Sources that must be floored at 0 per form instructions: - # "Net business income (including net farm income). If negative, enter 0" - # "Net royalty or rent income. If negative, enter 0" - floored_sources = { - "farm_operations_income", + # MCL 206.508(4) increases income by "(a) Any net business loss after + # netting all business income and loss" and "(b) Any net rental or + # royalty loss". The MI-1040CR nets each group and floors the total + # at zero, so a loss offsets income only within its own line. + # Line 16: U.S. Schedule C, Form 4797 Part II, Schedule E Parts II + # and III, and Schedule F. "If the total is negative enter 0." + business_sources = { "total_self_employment_income", + "other_net_gain", "partnership_s_corp_income", + "estate_income", + "farm_operations_income", + } + # Line 17: U.S. Schedule E Parts I, IV and V (rents, royalties, REMIC + # income, which has no input here, and farm rental income). "If the + # total is negative enter 0." + rental_sources = { "rental_income", "farm_rent_income", } - # Iterate through each source, applying flooring only to - # sources that require it per form instructions - total = 0 - for source in income_sources: - if source in floored_sources: - # Per MI-1040CR instructions: "If negative, enter 0" - total += max_(add(tax_unit, period, [source]), 0) + # 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. + business_income = 0 + rental_income = 0 + other_income = 0 + for source in p.household_resources: + if source in business_sources: + business_income += tax_unit_non_dep_add(tax_unit, period, [source]) + elif source in rental_sources: + rental_income += tax_unit_non_dep_add(tax_unit, period, [source]) else: - # Other sources are added as-is without flooring - total += add(tax_unit, period, [source]) + other_income += add(tax_unit, period, [source]) + total = other_income + max_(business_income, 0) + max_(rental_income, 0) - health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) # Line 30: "total adjustments from your U.S. Form 1040, Schedule 1". - # Capital losses are not Schedule 1 adjustments: they are already - # netted and limited in the capital gains line (Form 1040 line 7a), - # so the capital parts of loss_ald are not subtracted again. - above_the_line_deductions = tax_unit("above_the_line_deductions", period) - add( + # Business, rental and capital losses are income items (Schedule 1 + # Part I and Schedule D), not Part II adjustments. They are netted + # and floored on lines 16, 17 and 19 above, so loss_ald, which holds + # them, is left out. + adjustments = add( tax_unit, period, - ["capital_losses_allowed_against_gains", "limited_capital_loss"], - ) - return max_( - 0, - total - health_insurance_premiums - above_the_line_deductions, + [ + deduction + for deduction in parameters(period).gov.irs.ald.deductions + if deduction != "loss_ald" + ], ) + # Line 31: health insurance premiums. + health_insurance_premiums = add(tax_unit, period, ["health_insurance_premiums"]) + return max_(0, total - adjustments - health_insurance_premiums)