diff --git a/changelog.d/fix-ga-overtime-full-time-hourly.fixed.md b/changelog.d/fix-ga-overtime-full-time-hourly.fixed.md new file mode 100644 index 00000000000..ec868c99e19 --- /dev/null +++ b/changelog.d/fix-ga-overtime-full-time-hourly.fixed.md @@ -0,0 +1 @@ +Apply the full-time and hourly tests and the Railway Labor Act rule in Georgia's qualified overtime exclusion, O.C.G.A. 48-7-27(a)(16). diff --git a/policyengine_us/parameters/gov/states/ga/tax/income/agi/exclusions/overtime/full_time_weekly_hours.yaml b/policyengine_us/parameters/gov/states/ga/tax/income/agi/exclusions/overtime/full_time_weekly_hours.yaml new file mode 100644 index 00000000000..168cd223b07 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ga/tax/income/agi/exclusions/overtime/full_time_weekly_hours.yaml @@ -0,0 +1,21 @@ +description: Georgia treats an employee who usually works at least this many hours a week as full-time under the qualified overtime compensation exclusion. + +# O.C.G.A. § 48-7-27(a)(16)(A) says "full-time employee" and does not define +# the term. The value follows the income tax chapter's own usage: Chapter 7's +# job credits set the term at "a minimum of 35 hours a week". +values: + 2026-01-01: 35 + +metadata: + period: year + unit: hour + label: Georgia qualified overtime exclusion full-time weekly hours + reference: + - title: Georgia HB463 (2025-2026), Section 2-4 adding O.C.G.A. § 48-7-27(a)(16)(A) + href: https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=6 + - title: O.C.G.A. § 48-7-40(a)(5), new full-time employee job + href: https://codes.findlaw.com/ga/title-48-revenue-and-taxation/ga-code-sect-48-7-40/ + - title: O.C.G.A. § 48-7-40.1(a)(3), new full-time employee job + href: https://codes.findlaw.com/ga/title-48-revenue-and-taxation/ga-code-sect-48-7-40-1/ + - title: O.C.G.A. § 48-7-40.26(e)(10)(A), full-time employee + href: https://codes.findlaw.com/ga/title-48-revenue-and-taxation/ga-code-sect-48-7-40-26/ diff --git a/policyengine_us/tests/core/test_ga_overtime_exclusion_eligibility_invariants.py b/policyengine_us/tests/core/test_ga_overtime_exclusion_eligibility_invariants.py new file mode 100644 index 00000000000..d9f4c57f8bb --- /dev/null +++ b/policyengine_us/tests/core/test_ga_overtime_exclusion_eligibility_invariants.py @@ -0,0 +1,327 @@ +"""Invariants for who qualifies for Georgia's overtime exclusion. + +O.C.G.A. § 48-7-27(a)(16)(A) subtracts, for tax years 2026 through 2028, "any +amount of 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". (16)(B) continues: "Notwithstanding +subparagraph (A) of this paragraph, for employers governed by the federal +National Railway Labor Act, the exemption provided in this paragraph shall +apply to hourly component overtime compensation as defined in applicable +collective bargaining agreements." + +Each case below compares runs or sums a population, which a YAML case can't do. +The YAML file for the variable holds the worked examples. + +Hypothesis draws batches of Georgia tax units (single, joint, and either with +dependents), and a seeded population of 300 units adds breadth. Each person +gets an hourly flag, usual weekly hours on both sides of the 35-hour line, +overtime premiums and Railway Labor Act overtime, some above the cap. Each +batch runs as vectorized simulations with one input changed at a time. For +every tax unit: + +1. Differential: the exclusion equals an independent numpy sum, over the head + and spouse who are paid hourly and work at least 35 hours, of min(premium + plus Railway Labor Act overtime, $1,750), with both constants restated from + the law rather than read from the parameter files. +2. No leakage: zeroing both overtime amounts of everyone who fails either test + leaves the exclusion, Georgia subtractions and Georgia AGI unchanged. +3. Monotonicity: paying everyone hourly, raising hours, raising the premium or + raising Railway Labor Act overtime never lowers the exclusion. +4. The two amounts are interchangeable: swapping each person's premium and + Railway Labor Act overtime leaves the exclusion unchanged, since one limit + and the same tests cover both. +5. Flow: a change in the exclusion moves Georgia subtractions by the same + amount and Georgia AGI by the opposite amount. + +Case 1 also bounds the exclusion between 0 and $1,750 times the number of +filers. + +The seeded population also checks that each failing group holds amounts, 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): "up to $1,750.00". +CAP = 1_750 +# Chapter 7's job credits set full-time at "a minimum of 35 hours a week" +# (O.C.G.A. § 48-7-40(a)(5)). +FULL_TIME_HOURS = 35 +HOURS = [0, 20, 34, 34.99, 35, 35.01, 40, 60] + +OUTPUTS = [ + "ga_qualified_overtime_exclusion", + "ga_subtractions", + "ga_agi", +] +AMOUNTS = [ + "is_paid_hourly", + "weekly_hours_worked_before_lsr", + "fsla_overtime_premium", + "ga_rla_hourly_component_overtime", +] + +overtime = st.one_of(st.just(0.0), st.integers(1, 4_000).map(float)) + + +@st.composite +def person_amounts(draw): + premium = draw(overtime) + rla_overtime = draw(st.one_of(st.just(0.0), overtime)) + return { + "employment_income": ( + float(draw(st.integers(0, 60_000))) + premium + rla_overtime + ), + "is_paid_hourly": draw(st.booleans()), + "weekly_hours_worked_before_lsr": draw(st.sampled_from(HOURS)), + "fsla_overtime_premium": premium, + "ga_rla_hourly_component_overtime": rla_overtime, + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint"])) + n_dependents = draw(st.integers(0, 1)) + return { + "head": {"age": draw(st.integers(25, 70)), **draw(person_amounts())}, + "spouse": ( + {"age": draw(st.integers(25, 70)), **draw(person_amounts())} + if kind == "joint" + else None + ), + "dependents": [ + {"age": draw(st.integers(14, 18)), **draw(person_amounts())} + for _ in range(n_dependents) + ], + } + + +SEED = 20261031 + + +def _seeded_amounts(rng): + def some(low, high, p): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + premium = some(1, 4_000, 0.7) + rla_overtime = some(1, 4_000, 0.25) + return { + "employment_income": some(1, 60_000, 0.8) + premium + rla_overtime, + "is_paid_hourly": bool(rng.random() < 0.6), + "weekly_hours_worked_before_lsr": float(rng.choice(HOURS)), + "fsla_overtime_premium": premium, + "ga_rla_hourly_component_overtime": rla_overtime, + } + + +def _seeded_units(n=300): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + joint = rng.random() < 0.5 + units.append( + { + "head": {"age": int(rng.integers(25, 71)), **_seeded_amounts(rng)}, + "spouse": ( + {"age": int(rng.integers(25, 71)), **_seeded_amounts(rng)} + if joint + else None + ), + "dependents": [ + {"age": int(rng.integers(14, 19)), **_seeded_amounts(rng)} + for _ in range(int(rng.integers(0, 2))) + ], + } + ) + return units + + +def _situation(units, year, change): + 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, + **change(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, + **change(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}" + people[child] = { + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **change(d), + } + 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, change=lambda amounts: amounts): + sim = Simulation(situation=_situation(units, year, change)) + out = {name: np.asarray(sim.calculate(name, year), dtype=float) for name in OUTPUTS} + for name in AMOUNTS: + out[name] = np.asarray(sim.calculate(name, year), dtype=float) + out["hourly"] = out["is_paid_hourly"] > 0 + out["filer"] = ~np.asarray(sim.calculate("is_tax_unit_dependent", year), dtype=bool) + 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_qualified_overtime_exclusion"]), + ) + + +def _without_failing_amounts(amounts): + if ( + amounts["is_paid_hourly"] + and amounts["weekly_hours_worked_before_lsr"] >= FULL_TIME_HOURS + ): + return amounts + return { + **amounts, + "fsla_overtime_premium": 0.0, + "ga_rla_hourly_component_overtime": 0.0, + } + + +def _swap_overtime(amounts): + return { + **amounts, + "fsla_overtime_premium": amounts["ga_rla_hourly_component_overtime"], + "ga_rla_hourly_component_overtime": amounts["fsla_overtime_premium"], + } + + +def _expected(run): + passes = run["hourly"] & (run["weekly_hours_worked_before_lsr"] >= FULL_TIME_HOURS) + counted = run["fsla_overtime_premium"] + run["ga_rla_hourly_component_overtime"] + return _unit_sum(run, run["filer"] * passes * np.minimum(counted, CAP)) + + +def _at_least(lower, higher, name): + assert (higher[name] >= lower[name] - TOLERANCE).all(), name + + +def _check(units, year, *, require_failing_premiums=False): + base = _run(units, year) + exclusion = "ga_qualified_overtime_exclusion" + + # 1. Differential against numpy. + np.testing.assert_allclose(base[exclusion], _expected(base), atol=TOLERANCE) + + # 2. An amount that fails its tests reaches nothing. + no_failing_amounts = _run(units, year, _without_failing_amounts) + for name in (exclusion, "ga_subtractions", "ga_agi"): + np.testing.assert_allclose( + base[name], no_failing_amounts[name], atol=TOLERANCE, err_msg=name + ) + + # 3. Monotonicity in each input. + all_hourly = _run(units, year, lambda a: {**a, "is_paid_hourly": True}) + more_hours = _run( + units, + year, + lambda a: { + **a, + "weekly_hours_worked_before_lsr": a["weekly_hours_worked_before_lsr"] + 5, + }, + ) + more_premium = _run( + units, + year, + lambda a: { + **a, + "fsla_overtime_premium": a["fsla_overtime_premium"] + 100, + }, + ) + more_rla = _run( + units, + year, + lambda a: { + **a, + "ga_rla_hourly_component_overtime": a["ga_rla_hourly_component_overtime"] + + 100, + }, + ) + for higher in (all_hourly, more_hours, more_premium, more_rla): + _at_least(base, higher, exclusion) + + # 4. One limit and the same tests cover both amounts. + swapped = _run(units, year, _swap_overtime) + np.testing.assert_allclose(base[exclusion], swapped[exclusion], atol=TOLERANCE) + + # 5. The exclusion flows dollar for dollar. + change = all_hourly[exclusion] - base[exclusion] + np.testing.assert_allclose( + all_hourly["ga_subtractions"] - base["ga_subtractions"], + change, + atol=TOLERANCE, + ) + np.testing.assert_allclose( + all_hourly["ga_agi"] - base["ga_agi"], -change, atol=TOLERANCE + ) + + if require_failing_premiums: + full_time = base["weekly_hours_worked_before_lsr"] >= FULL_TIME_HOURS + for amount in ("fsla_overtime_premium", "ga_rla_hourly_component_overtime"): + held = base["filer"] & (base[amount] > 0) + assert (held & base["hourly"] & ~full_time).sum() >= 10, amount + assert (held & ~base["hourly"] & full_time).sum() >= 10, amount + assert (held & base["hourly"] & full_time).sum() >= 10, amount + assert (change > 0).sum() >= 10 + + +# 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_overtime_exclusion_eligibility_invariants(units): + _check(units, 2026) + + +@pytest.mark.parametrize("year", [2026, 2028]) +def test_seeded_population(year): + _check(_seeded_units(), year, require_failing_premiums=True) 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 index bacf65da749..abe7a8a8a4c 100644 --- 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 @@ -8,13 +8,16 @@ 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. +the filer cannot subtract them. test_ga_overtime_exclusion_eligibility_ +invariants.py covers the full-time and hourly tests. 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 +tips in or outside a Treasury tipped occupation, and overtime premiums and +Railway Labor Act overtime, some above the cap. Everyone works full-time and +earns an hourly wage, so a dependent's overtime would count but for the rule +under test. 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: @@ -45,11 +48,13 @@ CAP = 1_750 # A Treasury Tipped Occupation Code (102, bartenders) and none. TIPPED_CODE = 102 +FULL_TIME_HOURLY = {"is_paid_hourly": True, "weekly_hours_worked_before_lsr": 40} DEPENDENT_INPUTS = [ "tip_income", "treasury_tipped_occupation_code", "fsla_overtime_premium", + "ga_rla_hourly_component_overtime", ] OUTPUTS = [ "ga_cash_tip_exclusion", @@ -68,13 +73,18 @@ def person_amounts(draw): tip_income = draw(tips) overtime_premium = draw(overtime) - # Tips and the overtime premium are parts of employment income. + rla_overtime = draw(overtime) + # Tips and both overtime amounts are parts of employment income. base_wages = draw(st.integers(0, 60_000)) return { - "employment_income": float(base_wages) + tip_income + overtime_premium, + "employment_income": ( + float(base_wages) + tip_income + overtime_premium + rla_overtime + ), "tip_income": tip_income, "treasury_tipped_occupation_code": draw(st.sampled_from([0, TIPPED_CODE])), "fsla_overtime_premium": overtime_premium, + "ga_rla_hourly_component_overtime": rla_overtime, + **FULL_TIME_HOURLY, } @@ -105,11 +115,16 @@ def some(low, high, p=0.5): tip_income = some(1, 6_000, 0.6) overtime_premium = some(1, 4_000, 0.6) + rla_overtime = some(1, 4_000, 0.2) return { - "employment_income": some(1, 60_000, 0.8) + tip_income + overtime_premium, + "employment_income": ( + some(1, 60_000, 0.8) + tip_income + overtime_premium + rla_overtime + ), "tip_income": tip_income, "treasury_tipped_occupation_code": TIPPED_CODE if rng.random() < 0.7 else 0, "fsla_overtime_premium": overtime_premium, + "ga_rla_hourly_component_overtime": rla_overtime, + **FULL_TIME_HOURLY, } @@ -209,9 +224,12 @@ def _unit_sum(run, values): def _amounts(run): """Each person's own tips (tipped occupation only) and overtime, capped.""" tipped = run["treasury_tipped_occupation_code"] > 0 + overtime_total = ( + run["fsla_overtime_premium"] + run["ga_rla_hourly_component_overtime"] + ) return ( np.minimum(run["tip_income"] * tipped, CAP), - np.minimum(run["fsla_overtime_premium"], CAP), + np.minimum(overtime_total, CAP), ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_hb463_integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_hb463_integration.yaml index 38040a2d6b4..902771d2874 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_hb463_integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_hb463_integration.yaml @@ -156,6 +156,7 @@ age: 35 is_tax_unit_head: true is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 50_000 fsla_overtime_premium: 3_000 tax_units: @@ -178,6 +179,7 @@ age: 35 is_tax_unit_head: true is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 40_000 fsla_overtime_premium: 1_000 tax_units: @@ -198,6 +200,8 @@ head: age: 35 is_tax_unit_head: true + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 40_000 fsla_overtime_premium: 0 tax_units: @@ -222,12 +226,14 @@ age: 38 is_tax_unit_head: true is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 50_000 fsla_overtime_premium: 3_000 spouse: age: 36 is_tax_unit_spouse: true is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 50_000 fsla_overtime_premium: 3_000 tax_units: @@ -254,6 +260,7 @@ age: 35 is_tax_unit_head: true is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 50_000 fsla_overtime_premium: 3_000 tip_income: 4_000 @@ -269,6 +276,7 @@ output: ga_qualified_overtime_exclusion: 1_750 ga_cash_tip_exclusion: 1_750 + ga_subtractions: 3_500 - name: GA HB463 - TY 2029 overtime/tips exclusions self-repealed; no subtraction flows # Both paragraphs stand repealed 12/31/2028; subtractions list drops both @@ -281,6 +289,8 @@ head: age: 35 is_tax_unit_head: true + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 employment_income: 50_000 fsla_overtime_premium: 3_000 tip_income: 4_000 @@ -298,6 +308,8 @@ # and reserved on December 31, 2028. The subtractions list drops # ga_qualified_overtime_exclusion and ga_cash_tip_exclusion from # 2029-01-01, so neither flows through ga_subtractions. + ga_qualified_overtime_exclusion: 1_750 + ga_cash_tip_exclusion: 1_750 ga_subtractions: 0 - name: GA HB463 - TY 2026 cash tip exclusion caps at $1,750 for tipped occupations 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 index 9c740823439..7604f71e5ee 100644 --- 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 @@ -11,6 +11,8 @@ is_tax_unit_dependent: true employment_income: 8_000 fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 tax_units: tax_unit: members: [parent, teen] @@ -37,6 +39,8 @@ age: 17 employment_income: 8_000 fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 tax_units: tax_unit: members: [parent, teen] @@ -56,16 +60,22 @@ is_tax_unit_head: true employment_income: 40_000 fsla_overtime_premium: 3_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 spouse: age: 40 is_tax_unit_spouse: true employment_income: 40_000 fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 teen: age: 17 is_tax_unit_dependent: true employment_income: 8_000 fsla_overtime_premium: 2_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 tax_units: tax_unit: members: [head, spouse, teen] @@ -87,6 +97,8 @@ is_tax_unit_head: true employment_income: 8_000 fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 tax_units: tax_unit: members: [teen] @@ -97,3 +109,354 @@ state_code: GA output: ga_qualified_overtime_exclusion: 1_000 + +- name: A full-time hourly employee at the 35-hour threshold keeps the exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 35 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + # O.C.G.A. § 48-7-27(a)(16)(A): "a full-time employee paid by an hourly + # wage". Chapter 7's credits set full-time at "a minimum of 35 hours a + # week" (§ 48-7-40(a)(5)). + ga_qualified_overtime_exclusion: 1_000 + ga_subtractions: 1_000 + ga_agi: 49_000 + +- name: An hourly employee below the full-time threshold gets no exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 34.5 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_exclusion: 0 + ga_subtractions: 0 + ga_agi: 50_000 + +- name: A full-time salaried employee gets no exclusion for overtime + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: false + weekly_hours_worked_before_lsr: 45 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + # Salaried non-exempt pay can be qualified overtime compensation under + # IRC § 225, but (16)(A) reaches only an employee "paid by an hourly wage". + ga_qualified_overtime_exclusion: 0 + +- name: A record with overtime but no hourly or hours input gets no exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + fsla_overtime_premium: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_full_time_employee: false + ga_qualified_overtime_exclusion: 0 + +- name: Full-time status can be set directly when hours are not known + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + ga_qualified_overtime_full_time_employee: true + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_exclusion: 1_000 + +- name: A part-year full-time hourly employee keeps the exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 20_000 + fsla_overtime_premium: 500 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + weeks_worked: 20 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + # Full-time describes the employee's usual hours in the weeks worked, not + # an average over the whole year. + ga_qualified_overtime_exclusion: 500 + +- name: On a joint return each spouse passes or fails the tests alone + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 40_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 20 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: GA + output: + # Head: 1,000. Spouse: part-time, so none. + ga_qualified_overtime_exclusion: 1_000 + +- name: Railway Labor Act overtime counts up to the cap + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 80_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + ga_rla_hourly_component_overtime: 3_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + # O.C.G.A. § 48-7-27(a)(16)(B). The DOR's 2026 Employer's Withholding Tax + # Guide, p. 4, has employers report the full amount in W-2 Box 14a, code + # GA OT, and applies "the $1,750 limitation" when the employee files. + ga_qualified_overtime_exclusion: 1_750 + ga_subtractions: 1_750 + ga_agi: 78_250 + +- name: Part-time Railway Labor Act overtime gets no exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 30_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 20 + ga_rla_hourly_component_overtime: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + # (16)(B) replaces the compensation that counts. "Full-time employee paid + # by an hourly wage" stays. The DOR's guide, p. 22, places the Railway + # Labor Act rule inside that description. + ga_qualified_overtime_exclusion: 0 + +- name: Salaried Railway Labor Act overtime gets no exclusion + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 80_000 + is_paid_hourly: false + weekly_hours_worked_before_lsr: 40 + ga_rla_hourly_component_overtime: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_exclusion: 0 + +- name: Railway Labor Act overtime and qualified overtime share one cap + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 80_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + ga_rla_hourly_component_overtime: 1_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_exclusion: 1_750 + +- name: A dependent's Railway Labor Act overtime stays off the filer's return + period: 2026 + input: + people: + parent: + age: 45 + is_tax_unit_head: true + employment_income: 60_000 + teen: + age: 18 + is_tax_unit_dependent: true + employment_income: 8_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + ga_rla_hourly_component_overtime: 1_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: GA + output: + ga_qualified_overtime_exclusion: 0 + +- name: The tests reach 2028, the exclusion's last year + period: 2028 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 40_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: true + weekly_hours_worked_before_lsr: 40 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + fsla_overtime_premium: 1_000 + is_paid_hourly: false + weekly_hours_worked_before_lsr: 40 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: GA + output: + ga_qualified_overtime_exclusion: 1_000 + ga_subtractions: 1_000 + +- name: The cash tip exclusion has neither employee test + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 40_000 + is_paid_hourly: false + weekly_hours_worked_before_lsr: 20 + fsla_overtime_premium: 1_000 + ga_rla_hourly_component_overtime: 1_000 + tip_income: 4_000 + tip_income_deduction_occupation_requirement_met: true + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + households: + household: + members: [head] + state_code: GA + output: + ga_qualified_overtime_exclusion: 0 + ga_cash_tip_exclusion: 1_750 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.yaml b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.yaml new file mode 100644 index 00000000000..74f6e1e6ace --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.yaml @@ -0,0 +1,30 @@ +- name: 35 usual weekly hours is full-time + period: 2026 + input: + weekly_hours_worked_before_lsr: 35 + state_code: GA + output: + ga_qualified_overtime_full_time_employee: true + +- name: Just under 35 usual weekly hours is not full-time + period: 2026 + input: + weekly_hours_worked_before_lsr: 34.9 + state_code: GA + output: + ga_qualified_overtime_full_time_employee: false + +- name: A record without hours is not full-time + period: 2026 + input: + state_code: GA + output: + ga_qualified_overtime_full_time_employee: false + +- name: Only Georgia applies the test + period: 2026 + input: + weekly_hours_worked_before_lsr: 40 + state_code: SC + output: + ga_qualified_overtime_full_time_employee: false 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 c350e656f1f..a443e7ae93e 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 @@ -10,36 +10,42 @@ class ga_qualified_overtime_exclusion(Variable): defined_for = StateCode.GA # GA HB463 Section 2-4 adds O.C.G.A. § 48-7-27(a)(16). reference = ( - "https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=6" + "https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=6", + "https://dor.georgia.gov/document/document/2026-employers-tax-guide-updated-june-2026/download#page=4", ) def formula(tax_unit, period, parameters): - # GA HB463 § 48-7-27(a)(16) (TY 2026-2028): each full-time hourly - # employee may exclude up to $1,750 of qualified overtime compensation - # (defined by reference to IRC § 225) from Georgia taxable income. + # O.C.G.A. § 48-7-27(a)(16) (TY 2026-2028) excludes up to $1,750 per + # employee, for "a full-time employee paid by an hourly wage". Two + # amounts count toward that one limit: + # - (16)(A): qualified overtime compensation, defined by reference + # to IRC § 225, which `fsla_overtime_premium` holds. Reading + # `is_eligible_for_fsla_overtime` as well would add nothing: the + # variable is true whenever `is_paid_hourly` is. + # - (16)(B): "notwithstanding subparagraph (A)", for employers + # governed by the federal Railway Labor Act, "the exemption + # provided in this paragraph" applies to hourly component overtime + # compensation as collective bargaining agreements define it. That + # class replaces the § 225 amount, which 29 U.S.C. § 213(b)(2)-(3) + # puts out of reach for rail and air carrier employees. (B) changes + # nothing else in (A), so the limit and both employee tests stay. + # It has no years of its own, so it relies on (A). # - # Approximation: we use `fsla_overtime_premium`, the federally-defined - # Fair Labor Standards Act overtime premium, which by construction - # applies only to FLSA non-exempt workers (typically hourly). Known - # limitations: - # - The bill's "full-time" qualifier is not strictly enforced; - # a part-time hourly worker who happens to exceed 40 hours in - # some weeks would be credited the exclusion under the model. - # - § 48-7-27(a)(16)(B) extends the exclusion to railway workers - # under the National Railway Labor Act, defined by collective - # bargaining agreements. Railway workers are FLSA-exempt under - # 29 U.S.C. § 213(b)(2), so `fsla_overtime_premium` returns zero - # for them and they receive no exclusion under the model. This - # 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. + # The employer reporting rules in (16)(C)-(D) do not affect 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) + p = parameters(period).gov.states.ga.tax.income.agi.exclusions.overtime + overtime = add( + person, + period, + ["fsla_overtime_premium", "ga_rla_hourly_component_overtime"], + ) + eligible = person("is_paid_hourly", period) & person( + "ga_qualified_overtime_full_time_employee", 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) * not_dependent) + return tax_unit.sum(min_(overtime * eligible, p.cap) * not_dependent) diff --git a/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.py b/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.py new file mode 100644 index 00000000000..faf6dd1bb89 --- /dev/null +++ b/policyengine_us/variables/gov/states/ga/tax/income/ga_qualified_overtime_full_time_employee.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class ga_qualified_overtime_full_time_employee(Variable): + value_type = bool + entity = Person + label = "Full-time employee for the Georgia qualified overtime exclusion" + documentation = ( + "Whether the person is a full-time employee under O.C.G.A. § " + "48-7-27(a)(16)(A). The formula compares usual weekly hours with the " + "full-time threshold, so a record without hours is not full-time. Set " + "this variable directly when full-time status is known and hours " + "are not." + ) + definition_period = YEAR + defined_for = StateCode.GA + reference = ( + "https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=6" + ) + + def formula(person, period, parameters): + p = parameters(period).gov.states.ga.tax.income.agi.exclusions.overtime + # Usual hours in the weeks worked, so a part-year full-time employee + # stays full-time. Like other eligibility tests, this reads hours + # before labor supply responses, which do not change eligibility. + hours = person("weekly_hours_worked_before_lsr", period) + return hours >= p.full_time_weekly_hours diff --git a/policyengine_us/variables/gov/states/ga/tax/income/ga_rla_hourly_component_overtime.py b/policyengine_us/variables/gov/states/ga/tax/income/ga_rla_hourly_component_overtime.py new file mode 100644 index 00000000000..97e9953030e --- /dev/null +++ b/policyengine_us/variables/gov/states/ga/tax/income/ga_rla_hourly_component_overtime.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class ga_rla_hourly_component_overtime(Variable): + value_type = float + entity = Person + label = "Georgia Railway Labor Act hourly component overtime compensation" + unit = USD + documentation = ( + "Annual hourly component overtime compensation, as the applicable " + "collective bargaining agreement defines it, from an employer governed " + "by the federal Railway Labor Act, included in employment_income. It " + "counts only for a full-time employee paid by an hourly wage. " + "Employers report it in Form W-2, Box 14a, code GA OT, before the " + "$1,750 limit. The default dataset does not carry it, so " + "microsimulations on that dataset count none." + ) + definition_period = YEAR + defined_for = StateCode.GA + uprating = "calibration.gov.irs.soi.employment_income" + reference = ( + "https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=6", + "https://dor.georgia.gov/document/document/2026-employers-tax-guide-updated-june-2026/download#page=4", + ) diff --git a/policyengine_us/variables/household/income/person/is_paid_hourly.py b/policyengine_us/variables/household/income/person/is_paid_hourly.py index a5861ab92b2..f07211e5c54 100644 --- a/policyengine_us/variables/household/income/person/is_paid_hourly.py +++ b/policyengine_us/variables/household/income/person/is_paid_hourly.py @@ -5,4 +5,8 @@ class is_paid_hourly(Variable): value_type = bool entity = Person label = "is paid hourly" + documentation = ( + "Whether the person is paid by the hour, imputed from CPS ORG donors. " + "The default is false, so a record without it fails hourly-pay tests." + ) definition_period = YEAR diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index a7407abf0cd..1cea80fc061 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -2107,6 +2107,36 @@ candidates: - https://github.com/PolicyEngine/policyengine-us/pull/9969 state: unreviewed note: 'PE-US uses 60% of SMI for size 13, within the federal ceiling, for the limit and the payment bands (fl_liheap_income_limit.py). Distinguish the federal maximum from Florida''s chosen limit and verify the apparently erroneous row.' +- id: us-ga:cash-tips-rla-employees + program: income-tax + years: + from: 2026 + to: 2028 + type: tax + question: Does Georgia's cash tip subtraction reach an employee under the Railway Labor Act, although the Department + of Revenue's 2026 employer guide says it does not? + sources: + - kind: statute + citation: O.C.G.A. §48-7-27(a)(17)(A), added by HB463 (2026) Section 2-4 + url: https://www.legis.ga.gov/api/legislation/document/20252026/249080#page=7 + - kind: guidance + citation: Georgia DOR, 2026 Employer's Withholding Tax Guide, p. 5 + url: https://dor.georgia.gov/document/document/2026-employers-tax-guide-updated-june-2026/download#page=5 + readings: + statute: Paragraph (17) subtracts up to $1,750 of cash tips with no Railway Labor Act condition. HB463 names that + Act only in paragraph (16)(B). + other: The guide says the provision does not apply to employees whose employment is subject to the federal Railway + Labor Act. + example: An airline employee with $4,000 of cash tips in a qualifying occupation subtracts $1,750 under the statute + and nothing under the guide. + pe_us: + follows: statute + code: + - policyengine_us/variables/gov/states/ga/tax/income/ga_cash_tip_exclusion.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10562 + state: ruled + note: The model has no Railway Labor Act status for this test, so the exclusion applies to every employee. - id: us-ga:liheap-income-floor-vs-smi-limit program: liheap years: