Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.d/fix-oh-dependent-capital-gain.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Exclude tax-unit dependents' qualifying capital gain deductions from the filers' Ohio AGI and modified AGI accounting.
1 change: 1 addition & 0 deletions changelog.d/fix-oh-modified-agi-addbacks.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ohio now applies the business income deduction and the 3% tax on taxable business income, and modified adjusted gross income adds back the business income deduction and, from 2026, the qualifying capital gain deduction; personal exemptions and the exemption credit use modified adjusted gross income.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
description: Ohio taxes taxable business income at this flat rate.
values:
2021-01-01: 0.03
metadata:
unit: /1
period: year
label: Ohio business income tax rate
reference:
- title: R.C. 5747.02(A)(4)(a)
href: https://codes.ohio.gov/ohio-revised-code/section-5747.02
- title: 2024 Ohio Schedule of Business Income, line 16
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2024/1040-bundle-original-fi.pdf#page=5
- title: 2024 Ohio IT 1040 Instructions, Ohio Schedule of Business Income, Part 3
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2024/it1040-booklet.pdf#page=27
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
description: Ohio counts these income sources as business income on the Schedule of Business Income.
values:
2021-01-01:
- self_employment_income # Schedule C, line 2
- sstb_self_employment_income # Schedule C, line 2
- partnership_s_corp_income # Schedule E pass-through income, line 4
- farm_operations_income # Schedule F, line 6
metadata:
unit: list
period: year
label: Ohio business income sources
reference:
# "Generally, income recognized by a sole proprietorship or pass-through
# entity is business income." Interest, dividends, capital gains, rents
# (including farm rent), royalties, trust income and Form 4797 gains are
# business income only when they arise in a trade or business, which the
# model cannot observe, so they are treated as nonbusiness income. Wages
# and guaranteed payments a pass-through pays a 20%-or-more owner
# (schedule line 5) are not modeled.
- title: R.C. 5747.01(B) and (C)
href: https://codes.ohio.gov/ohio-revised-code/section-5747.01
- title: 2024 Ohio IT 1040 Instructions, Definitions and Examples of Business and Nonbusiness Income
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2024/it1040-booklet.pdf#page=11
- title: 2024 Ohio IT 1040 Instructions, Ohio Schedule of Business Income
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2024/it1040-booklet.pdf#page=26
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
description: Ohio limits the business income deduction to this amount, by filing status.
metadata:
propagate_metadata_to_children: true
label: Ohio business income deduction cap
period: year
unit: currency-USD
breakdown:
- filing_status
reference:
- title: R.C. 5747.01(A)(28)
href: https://codes.ohio.gov/ohio-revised-code/section-5747.01
- title: 2024 Ohio Schedule of Business Income, line 12
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2024/1040-bundle-original-fi.pdf#page=5
- title: 2021 Ohio IT 1040 Instructions, Business Income
href: https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2021/pit-it1040-booklet.pdf#page=10
SINGLE:
2021-01-01: 250_000
JOINT:
2021-01-01: 250_000
SEPARATE:
2021-01-01: 125_000
HEAD_OF_HOUSEHOLD:
2021-01-01: 250_000
SURVIVING_SPOUSE:
2021-01-01: 250_000
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ values:
- oh_unreimbursed_medical_care_expense_deduction_person
- us_govt_interest_person
- salt_refund_income
- oh_business_income_deduction_person # Schedule of Adjustments, line 13
- oh_qualifying_capital_gain_deduction # R.C. 5747.01(A)(34), from 2026

metadata:
unit: list
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
description: Ohio allows the qualifying capital gain deduction if this is true.
values:
2021-01-01: false
2026-01-01: true
metadata:
unit: bool
period: year
label: Ohio qualifying capital gain deduction in effect
reference:
- title: R.C. 5747.79(B)
href: https://codes.ohio.gov/ohio-revised-code/section-5747.79
- title: R.C. 5747.01(A)(34)
href: https://codes.ohio.gov/ohio-revised-code/section-5747.01
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ def formula(tax_unit, period, parameters):
# Negative amount sentinel = use the discrete MAGI-stepped schedule
# (no-op default, since the contrib schedule mirrors the baseline);
# a value >= 0 applies a flat per-dependent amount instead.
agi = tax_unit("oh_agi", period)
stepped_per = p.schedule.amount.calc(agi)
# The baseline schedule is stepped by modified AGI (R.C. 5747.025).
modified_agi = tax_unit("oh_modified_agi", period)
stepped_per = p.schedule.amount.calc(modified_agi)
per_dependent = where(p.amount < 0, stepped_per, p.amount)
return count * per_dependent

Expand Down Expand Up @@ -77,8 +78,8 @@ class oh_personal_exemptions(Variable):

def formula(tax_unit, period, parameters):
p_base = parameters(period).gov.states.oh.tax.income.exemptions.personal
agi = tax_unit("oh_agi", period)
per_person = p_base.amount.calc(agi)
modified_agi = tax_unit("oh_modified_agi", period)
per_person = p_base.amount.calc(modified_agi)

# Personal portion = all eligible persons minus the dependents that
# are separated out (over-age dependents stay in the personal count).
Expand Down
229 changes: 229 additions & 0 deletions policyengine_us/tests/core/test_oh_filer_deduction_invariants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Ohio deductions and addbacks use income on the filers' return.

R.C. 5747.79(A)(1) permits qualifying capital gain only to the extent it
has not been excluded from federal or Ohio AGI; subsection (B) limits the
deduction to deductible payroll. A dependent's income is excluded from the
filers' federal AGI. R.C. 5747.01(A)(28) limits the business deduction to
business income in federal AGI, capped at $250,000 for single and joint
returns. Subsection (II) adds both deductions back to Ohio modified AGI.

Sources: https://codes.ohio.gov/ohio-revised-code/section-5747.79 and
https://codes.ohio.gov/ohio-revised-code/section-5747.01.

Each Hypothesis example evaluates six tax units and their twins together:
one or two filers and one through three dependent children. Actual capital gains and
S-corporation income avoid unrelated loss allocation. An independent sum
over filers checks federal AGI, the deductions and Ohio AGI, then a twin
tax unit with dependent gain and business inputs zeroed checks that those
inputs cannot change the filers' deductions or either Ohio AGI measure.
"""

import numpy as np
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

from policyengine_us import Simulation

YEAR = 2026
TOLERANCE = 0.01
DEPENDENT_INPUTS = (
"long_term_capital_gains",
"s_corp_income",
"oh_qualifying_capital_gain",
"oh_qualifying_capital_gain_deductible_payroll",
)
PERSON_OUTPUTS = (
"adjusted_gross_income_person",
"oh_business_income_deduction_person",
"oh_qualifying_capital_gain_deduction",
)
UNIT_OUTPUTS = (
"adjusted_gross_income",
"oh_business_income_deduction",
"oh_agi",
"oh_modified_agi",
)


@st.composite
def person_amounts(draw, *, dependent):
return {
"employment_income": draw(st.integers(1, 100_000)),
"s_corp_income": draw(st.integers(1 if dependent else 0, 350_000)),
"long_term_capital_gains": draw(st.integers(1 if dependent else 0, 400_000)),
"oh_qualifying_capital_gain": draw(
st.integers(1 if dependent else -50_000, 400_000)
),
"oh_qualifying_capital_gain_deductible_payroll": draw(
st.integers(1 if dependent else -50_000, 400_000)
),
}


@st.composite
def tax_unit_batch(draw):
return [
{
"filers": [draw(person_amounts(dependent=False)) for _ in range(n_filers)],
"dependents": [
draw(person_amounts(dependent=True)) for _ in range(n_dependents)
],
}
for n_filers in (1, 2)
for n_dependents in (1, 2, 3)
]


def _run(units):
situation = {
"people": {},
"tax_units": {},
"households": {},
"marital_units": {},
}
# Evaluate each unit and its twin in one vectorized simulation.
for i, unit in enumerate(units + units):
members, filers = [], []
for j, amounts in enumerate(unit["filers"] + unit["dependents"]):
dependent = j >= len(unit["filers"])
name = f"person_{i}_{j}"
values = {
**amounts,
"age": 10 if dependent else 45,
"is_tax_unit_head": j == 0,
"is_tax_unit_spouse": j == 1 and not dependent,
"is_tax_unit_dependent": dependent,
}
if dependent and i >= len(units):
values.update({variable: 0 for variable in DEPENDENT_INPUTS})
situation["people"][name] = {
variable: {YEAR: value} for variable, value in values.items()
}
members.append(name)
if dependent:
situation["marital_units"][name] = {"members": [name]}
else:
filers.append(name)
situation["marital_units"][f"filers_{i}"] = {"members": filers}
situation["tax_units"][f"tax_unit_{i}"] = {
"members": members,
"filing_status": {YEAR: "JOINT" if len(filers) == 2 else "SINGLE"},
}
situation["households"][f"household_{i}"] = {
"members": members,
"state_code": {YEAR: "OH"},
}
simulation = Simulation(situation=situation)
outputs = {
variable: np.asarray(simulation.calculate(variable, YEAR), dtype=float)
for variable in PERSON_OUTPUTS + UNIT_OUTPUTS
}
outputs["dependent"] = np.asarray(
simulation.calculate("is_tax_unit_dependent", YEAR), dtype=bool
)
index = simulation.populations["tax_unit"].members_entity_id
n_people = len(index) // 2
runs = []
for half in (0, 1):
results = {}
for variable in PERSON_OUTPUTS + ("dependent",):
results[variable] = outputs[variable][
half * n_people : (half + 1) * n_people
]
for variable in UNIT_OUTPUTS:
results[variable] = outputs[variable][
half * len(units) : (half + 1) * len(units)
]
results["unit"] = index[half * n_people : (half + 1) * n_people] - half * len(
units
)
runs.append(results)
return runs


def _unit_sum(run, amounts):
return np.bincount(
run["unit"], weights=amounts, minlength=len(run["adjusted_gross_income"])
)


@settings(
max_examples=5,
deadline=None,
derandomize=True,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)
@given(tax_unit_batch())
def test_dependent_income_cannot_change_ohio_filer_deductions(units):
run, zeroed = _run(units)
filer = ~run["dependent"]
people = [
person for unit in units for person in unit["filers"] + unit["dependents"]
]

def inputs(variable):
return np.array([person[variable] for person in people], dtype=float)

expected_person_agi = filer * (
inputs("employment_income")
+ inputs("s_corp_income")
+ inputs("long_term_capital_gains")
)
expected_agi = _unit_sum(run, expected_person_agi)
expected_business = np.minimum(
250_000, _unit_sum(run, filer * inputs("s_corp_income"))
)
expected_capital_person = filer * np.minimum.reduce(
[
np.maximum(0, inputs("oh_qualifying_capital_gain")),
inputs("long_term_capital_gains"),
np.maximum(0, inputs("oh_qualifying_capital_gain_deductible_payroll")),
]
)
expected_capital = _unit_sum(run, expected_capital_person)

for results in (run, zeroed):
for variable in PERSON_OUTPUTS:
np.testing.assert_array_equal(results[variable][run["dependent"]], 0)
np.testing.assert_allclose(
results["adjusted_gross_income_person"],
expected_person_agi,
atol=TOLERANCE,
)
np.testing.assert_allclose(
results["adjusted_gross_income"], expected_agi, atol=TOLERANCE
)
np.testing.assert_allclose(
results["oh_qualifying_capital_gain_deduction"],
expected_capital_person,
atol=TOLERANCE,
)
np.testing.assert_allclose(
results["oh_business_income_deduction"],
expected_business,
atol=TOLERANCE,
)
np.testing.assert_allclose(
_unit_sum(results, results["oh_business_income_deduction_person"]),
expected_business,
atol=TOLERANCE,
)
np.testing.assert_allclose(
results["oh_agi"],
expected_agi - expected_business - expected_capital,
atol=TOLERANCE,
)
np.testing.assert_allclose(
results["oh_modified_agi"], expected_agi, atol=TOLERANCE
)
for variable in UNIT_OUTPUTS:
np.testing.assert_allclose(
run[variable], zeroed[variable], atol=TOLERANCE, err_msg=variable
)
for variable in PERSON_OUTPUTS:
np.testing.assert_allclose(
run[variable][filer],
zeroed[variable][filer],
atol=TOLERANCE,
err_msg=variable,
)
Loading
Loading