diff --git a/changelog.d/fix-ga-tips-overtime-dependents.fixed.md b/changelog.d/fix-ga-tips-overtime-dependents.fixed.md new file mode 100644 index 00000000000..0e29a51c40d --- /dev/null +++ b/changelog.d/fix-ga-tips-overtime-dependents.fixed.md @@ -0,0 +1 @@ +Georgia's HB463 cash tip and overtime exclusions no longer count a dependent's tips or overtime, which are the dependent's own income and not in the filer's federal AGI. diff --git a/policyengine_us/tests/core/test_ga_tip_overtime_exclusion_dependent_invariants.py b/policyengine_us/tests/core/test_ga_tip_overtime_exclusion_dependent_invariants.py new file mode 100644 index 00000000000..bacf65da749 --- /dev/null +++ b/policyengine_us/tests/core/test_ga_tip_overtime_exclusion_dependent_invariants.py @@ -0,0 +1,275 @@ +"""Invariants for dependents in Georgia's HB463 tip and overtime exclusions. + +O.C.G.A. § 48-7-27(a) makes Georgia taxable net income "the taxpayer's +federal adjusted gross income ... less" its listed subtractions. HB463 +(2026) adds two for tax years 2026 through 2028: (16)(A) "qualified overtime +compensation, as such term is defined in Section 225 of the Internal Revenue +Code, up to $1,750.00 received by a full-time employee paid by an hourly +wage", and (17)(A) "any amount up to $1,750.00 received in cash tips". A +dependent's tips and overtime are the dependent's own income (26 U.S.C. +73(a) for a child's services). They are not in the filer's federal AGI, so +the filer cannot subtract them. + +Hypothesis draws batches of Georgia tax units (single, head of household +with dependents, joint with and without dependents), and a seeded population +of 150 units adds breadth. Heads, spouses and dependents each get wages, +tips in or outside a Treasury tipped occupation, and overtime premiums, some +above the cap. Each batch runs as one vectorized simulation, twice: with the +dependents' tips, occupation codes and overtime as drawn and with them set +to zero. For every tax unit: + +1. The dependents' tips and overtime never change either exclusion, Georgia + subtractions, Georgia AGI, Georgia taxable income or Georgia income tax. +2. Differential: each exclusion equals an independent numpy sum, over the + head and spouse only, of their own amount (tips only from a tipped + occupation) capped at $1,750 each, restated from the statute rather than + read from the parameter files. For units without dependents this is the + previous all-member sum, so they see no change. +3. Each exclusion lies between 0 and $1,750 times the number of filers (one, + or two on a joint return). + +The seeded population also checks that it contains units whose dependents' +amounts would have changed the old all-member sum, so 1 and 2 are not +vacuous. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +# O.C.G.A. § 48-7-27(a)(16)(A) and (17)(A): "up to $1,750.00". +CAP = 1_750 +# A Treasury Tipped Occupation Code (102, bartenders) and none. +TIPPED_CODE = 102 + +DEPENDENT_INPUTS = [ + "tip_income", + "treasury_tipped_occupation_code", + "fsla_overtime_premium", +] +OUTPUTS = [ + "ga_cash_tip_exclusion", + "ga_qualified_overtime_exclusion", + "ga_subtractions", + "ga_agi", + "ga_taxable_income", + "ga_income_tax", +] + +tips = st.one_of(st.just(0.0), st.integers(1, 6_000).map(float)) +overtime = st.one_of(st.just(0.0), st.integers(1, 4_000).map(float)) + + +@st.composite +def person_amounts(draw): + tip_income = draw(tips) + overtime_premium = draw(overtime) + # Tips and the overtime premium are parts of employment income. + base_wages = draw(st.integers(0, 60_000)) + return { + "employment_income": float(base_wages) + tip_income + overtime_premium, + "tip_income": tip_income, + "treasury_tipped_occupation_code": draw(st.sampled_from([0, TIPPED_CODE])), + "fsla_overtime_premium": overtime_premium, + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": {"age": draw(st.integers(25, 70)), **draw(person_amounts())}, + "spouse": ( + {"age": draw(st.integers(25, 70)), **draw(person_amounts())} + if kind.startswith("joint") + else None + ), + "dependents": [ + {"age": draw(st.integers(14, 23)), **draw(person_amounts())} + for _ in range(n_dependents) + ], + } + + +SEED = 20261023 + + +def _seeded_amounts(rng): + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + tip_income = some(1, 6_000, 0.6) + overtime_premium = some(1, 4_000, 0.6) + return { + "employment_income": some(1, 60_000, 0.8) + tip_income + overtime_premium, + "tip_income": tip_income, + "treasury_tipped_occupation_code": TIPPED_CODE if rng.random() < 0.7 else 0, + "fsla_overtime_premium": overtime_premium, + } + + +def _seeded_units(n=150): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": {"age": int(rng.integers(25, 71)), **_seeded_amounts(rng)}, + "spouse": ( + {"age": int(rng.integers(25, 71)), **_seeded_amounts(rng)} + if kind.startswith("joint") + else None + ), + "dependents": [ + {"age": int(rng.integers(14, 24)), **_seeded_amounts(rng)} + for _ in range(n_dependents) + ], + } + ) + return units + + +def _situation(units, year, *, zero_dependents): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": False, + **u["head"], + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = { + "is_tax_unit_head": False, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **u["spouse"], + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + amounts = dict(d) + if zero_dependents: + # Wages stay as drawn, so only the tip and overtime amounts + # (and the occupation code) change. + amounts.update({name: 0 for name in DEPENDENT_INPUTS}) + people[child] = { + "is_full_time_student": d["age"] >= 19, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **amounts, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "GA"}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, zero_dependents): + sim = Simulation(situation=_situation(units, year, zero_dependents=zero_dependents)) + out = {name: np.asarray(sim.calculate(name, year), dtype=float) for name in OUTPUTS} + out["is_tax_unit_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + for name in DEPENDENT_INPUTS: + out[name] = np.asarray(sim.calculate(name, year), dtype=float) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["ga_cash_tip_exclusion"]) + ) + + +def _amounts(run): + """Each person's own tips (tipped occupation only) and overtime, capped.""" + tipped = run["treasury_tipped_occupation_code"] > 0 + return ( + np.minimum(run["tip_income"] * tipped, CAP), + np.minimum(run["fsla_overtime_premium"], CAP), + ) + + +def _check(units, year, *, require_dependent_amounts=False): + run = _run(units, year, zero_dependents=False) + zeroed = _run(units, year, zero_dependents=True) + filer = ~run["is_tax_unit_dependent"] + + # 1. Dependents' tips and overtime never reach the filer's Georgia return. + for name in OUTPUTS: + np.testing.assert_allclose( + run[name], zeroed[name], atol=TOLERANCE, err_msg=name + ) + + # 2. Differential against numpy sums over the head and spouse. + capped_tips, capped_overtime = _amounts(run) + for name, capped in ( + ("ga_cash_tip_exclusion", capped_tips), + ("ga_qualified_overtime_exclusion", capped_overtime), + ): + np.testing.assert_allclose( + run[name], _unit_sum(run, filer * capped), atol=TOLERANCE, err_msg=name + ) + has_dependent = _unit_sum(run, (~filer).astype(float)) > 0 + np.testing.assert_allclose( + run[name][~has_dependent], + _unit_sum(run, capped)[~has_dependent], + atol=TOLERANCE, + err_msg=name, + ) + if require_dependent_amounts: + # The old all-member sum differs from the filers' sum somewhere. + leaked = _unit_sum(run, capped) - _unit_sum(run, filer * capped) + assert (leaked > 0).sum() >= 10, name + + # 3. Bounds: 0 to $1,750 per filer. + filers = _unit_sum(run, filer.astype(float)) + for name in ("ga_cash_tip_exclusion", "ga_qualified_overtime_exclusion"): + assert (run[name] >= -TOLERANCE).all(), name + assert (run[name] <= CAP * filers + TOLERANCE).all(), name + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=30)) +def test_dependents_tips_and_overtime_stay_off_the_filers_georgia_return(units): + _check(units, 2026) + + +@pytest.mark.parametrize("year", [2026, 2028]) +def test_seeded_population(year): + _check(_seeded_units(), year, require_dependent_amounts=True) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_cash_tip_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_cash_tip_exclusion.yaml new file mode 100644 index 00000000000..496f820ad44 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_cash_tip_exclusion.yaml @@ -0,0 +1,105 @@ +- name: A dependent's tips do not reduce the filer's Georgia AGI + period: 2026 + input: + people: + parent: + age: 45 + is_tax_unit_head: true + employment_income: 60_000 + teen: + age: 17 + is_tax_unit_dependent: true + employment_income: 8_000 + treasury_tipped_occupation_code: 102 + tip_income: 6_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: GA + output: + # O.C.G.A. § 48-7-27(a) subtracts the exclusion from the taxpayer's + # federal AGI. The teen's tips are the teen's own income (26 U.S.C. + # 73(a)) and are not in the parent's AGI. + ga_cash_tip_exclusion: 0 + ga_subtractions: 0 + ga_agi: 60_000 + +- name: A dependent's tips do not count when the model infers the dependent + period: 2026 + input: + people: + parent: + age: 45 + employment_income: 60_000 + teen: + age: 17 + employment_income: 8_000 + treasury_tipped_occupation_code: 102 + tip_income: 6_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: GA + output: + ga_cash_tip_exclusion: 0 + +- name: On a joint return only the spouses' own tips count, each capped + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 40_000 + treasury_tipped_occupation_code: 102 + tip_income: 4_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + treasury_tipped_occupation_code: 102 + tip_income: 1_000 + teen: + age: 17 + is_tax_unit_dependent: true + employment_income: 8_000 + treasury_tipped_occupation_code: 102 + tip_income: 6_000 + tax_units: + tax_unit: + members: [head, spouse, teen] + filing_status: JOINT + households: + household: + members: [head, spouse, teen] + state_code: GA + output: + # Head: min(4,000, 1,750) = 1,750. Spouse: 1,000. Teen: none. + ga_cash_tip_exclusion: 2_750 + +- name: A teen filing their own return keeps the exclusion for their own tips + period: 2026 + input: + people: + teen: + age: 17 + is_tax_unit_head: true + employment_income: 8_000 + treasury_tipped_occupation_code: 102 + tip_income: 6_000 + tax_units: + tax_unit: + members: [teen] + filing_status: SINGLE + households: + household: + members: [teen] + state_code: GA + output: + ga_cash_tip_exclusion: 1_750 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.yaml new file mode 100644 index 00000000000..9c740823439 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.yaml @@ -0,0 +1,99 @@ +- name: A dependent's overtime does not reduce the filer's Georgia AGI + period: 2026 + input: + people: + parent: + age: 45 + is_tax_unit_head: true + employment_income: 60_000 + teen: + age: 17 + is_tax_unit_dependent: true + employment_income: 8_000 + fsla_overtime_premium: 1_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: GA + output: + # O.C.G.A. § 48-7-27(a) subtracts the exclusion from the taxpayer's + # federal AGI. The teen's overtime is the teen's own income (26 U.S.C. + # 73(a)) and is not in the parent's AGI. + ga_qualified_overtime_exclusion: 0 + ga_subtractions: 0 + ga_agi: 60_000 + +- name: A dependent's overtime does not count when the model infers the dependent + period: 2026 + input: + people: + parent: + age: 45 + employment_income: 60_000 + teen: + age: 17 + employment_income: 8_000 + fsla_overtime_premium: 1_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: GA + output: + ga_qualified_overtime_exclusion: 0 + +- name: On a joint return only the spouses' own overtime counts, each capped + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 40_000 + fsla_overtime_premium: 3_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + fsla_overtime_premium: 1_000 + teen: + age: 17 + is_tax_unit_dependent: true + employment_income: 8_000 + fsla_overtime_premium: 2_000 + tax_units: + tax_unit: + members: [head, spouse, teen] + filing_status: JOINT + households: + household: + members: [head, spouse, teen] + state_code: GA + output: + # Head: min(3,000, 1,750) = 1,750. Spouse: 1,000. Teen: none. + ga_qualified_overtime_exclusion: 2_750 + +- name: A teen filing their own return keeps the exclusion for their own overtime + period: 2026 + input: + people: + teen: + age: 17 + is_tax_unit_head: true + employment_income: 8_000 + fsla_overtime_premium: 1_000 + tax_units: + tax_unit: + members: [teen] + filing_status: SINGLE + households: + household: + members: [teen] + state_code: GA + output: + ga_qualified_overtime_exclusion: 1_000 diff --git a/policyengine_us/variables/gov/states/ga/tax/income/ga_cash_tip_exclusion.py b/policyengine_us/variables/gov/states/ga/tax/income/ga_cash_tip_exclusion.py index 4a95ac1f558..c62e1d58915 100644 --- a/policyengine_us/variables/gov/states/ga/tax/income/ga_cash_tip_exclusion.py +++ b/policyengine_us/variables/gov/states/ga/tax/income/ga_cash_tip_exclusion.py @@ -34,11 +34,17 @@ def formula(tax_unit, period, parameters): # the Treasury list and excludes Specified Service Trades or # Businesses (via `~is_sstb`) — the IRC § 199A SSTB list is the # standard federal proxy for the occupation-exclusion clause. + # + # § 48-7-27(a) subtracts the exclusion from the taxpayer's federal + # adjusted gross income. A dependent's tips are the dependent's own + # income (26 U.S.C. 73(a) for a child's services), left out of the + # filer's federal AGI (see irs_gross_income), so they never count. person = tax_unit.members tip_income = person("tip_income", period) occupation_eligible = person( "tip_income_deduction_occupation_requirement_met", period ) - qualified_tips = tip_income * occupation_eligible + not_dependent = ~person("is_tax_unit_dependent", period) + qualified_tips = tip_income * occupation_eligible * not_dependent cap = parameters(period).gov.states.ga.tax.income.agi.exclusions.tips.cap return tax_unit.sum(min_(qualified_tips, cap)) diff --git a/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.py b/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.py index 485b73dbef7..c350e656f1f 100644 --- a/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.py +++ b/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_exclusion.py @@ -33,7 +33,13 @@ def formula(tax_unit, period, parameters): # is a known under-modeling of a specifically eligible group. # - The employer reporting obligations in § 48-7-27(a)(16)(C)-(D) # are administrative and do not affect household tax liability. + # + # § 48-7-27(a) subtracts the exclusion from the taxpayer's federal + # adjusted gross income. A dependent's overtime is the dependent's own + # income (26 U.S.C. 73(a) for a child's services), left out of the + # filer's federal AGI (see irs_gross_income), so it never counts. person = tax_unit.members overtime = person("fsla_overtime_premium", period) + not_dependent = ~person("is_tax_unit_dependent", period) cap = parameters(period).gov.states.ga.tax.income.agi.exclusions.overtime.cap - return tax_unit.sum(min_(overtime, cap)) + return tax_unit.sum(min_(overtime, cap) * not_dependent)