Automating ACA Full-Time Equivalent Tracking

Automating the full-time equivalent (FTE) rollup that drives Applicable Large Employer (ALE) status is where most payroll compliance pipelines silently break: a naive float accumulation, a misaligned month boundary, or an uncapped non-full-time employee can flip a 49.9-employee average to 50 and pull an employer into IRC § 4980H shared-responsibility liability it does not owe. This pattern is the FTE-counting layer of the ACA Tracking Logic module within the Core Architecture & Compliance Mapping for Payroll Systems framework, and it assumes Hours of Service have already been normalized upstream.

Problem Framing

The ALE test sounds simple — “50 full-time employees including FTEs” — and that is exactly why naive implementations fail. Two distinct counts feed one annual average, and they use different thresholds, different caps, and different rounding rules:

  1. Full-time count. Every employee with at least 130 Hours of Service in a calendar month is full-time for that month. There is no cap and no division — they count as one whole person.
  2. FTE count. Every non-full-time employee’s monthly hours are capped at 120, summed across all such employees, and divided by 120 to produce the month’s fractional headcount, which is then floored.

A pipeline that runs all hours through a single threshold, or that applies the 120 divisor to full-time hours, will overstate the count. A pipeline that rounds the monthly FTE figure with round() instead of truncating, or that lets IEEE-754 drift accumulate over twelve months, will cross the 50-employee line on the wrong side. Because the determination is a step function — 49.99 is not an ALE, 50.00 is — every fractional hour at the boundary is load-bearing.

The scenario is further complicated by employees who change status mid-year, seasonal workers, and rehires. These are not edge cases you can patch later; they change the denominator. The same effective-dating discipline used in the FLSA Threshold Mapping gate applies here: the rule in force is the one effective for the measurement month, not the one in force when the batch runs.

Prerequisites & Data Requirements

Before the FTE engine runs, each input record must already satisfy the canonical contract defined by the Data Boundary Definitions layer. The minimum per-record fields are:

Field Type Constraint
employee_id str Stable across the full measurement year; never reused
year int Four-digit calendar year of the measurement month
month int 112; aligns to a calendar month, not a pay period
hours_of_service Decimal Countable HOS only (paid work + paid leave), quantized to 0.01
employment_category str One of ONGOING, SEASONAL, VARIABLE_HOUR, NEW_HIRE
jurisdiction str Worksite jurisdiction code for parallel state determinations

Hard preconditions:

  • HOS already normalized. Countable hours follow 26 CFR § 54.4980H-1(a)(24): every hour paid or owed, including paid leave. Unpaid leave is excluded upstream. The FTE engine never re-derives hours from punches.
  • Decimal everywhere. Hours arrive as decimal.Decimal, not float. The 130- and 120-hour tests are exact comparisons; binary floating point cannot represent them faithfully.
  • One record per employee-month. Deduplication is idempotent on a record hash before this stage. Two rows for the same employee_id/year/month is a quarantine condition, not an addition.
  • Seasonal exclusion data available. You need cumulative days-of-employment per worker to apply the seasonal-worker exception correctly during annual averaging.
ACA full-time equivalent rollup and ALE determination flow One Decimal-hours record per employee-month enters the 130-hour test. At or above 130 Hours of Service the employee is FULL_TIME and counts as one whole person with no cap and no divisor. Below 130 the employee is NON_FULL_TIME: monthly hours are capped at 120 via min(HOS, 120), summed across all such employees, divided by 120 and floored to the month's FTE count. Full-time count plus the floored monthly FTE produces the monthly headcount; twelve monthly headcounts are summed, divided by twelve and floored to the annual ALE average. An average below 50 is not an Applicable Large Employer; an average at or above 50 is an ALE and IRC Section 4980H shared-responsibility liability applies. Employee-month record Decimal HOS · 1 per emp-month 130-hour test FULL_TIME counts as 1 whole person no cap · no divisor NON_FULL_TIME cap hours at 120 min(HOS, 120) Monthly FTE ⌊ Σ min(HOS,120) ÷ 120 ⌋ Monthly headcount FTₘ + FTEₘ Annual ALE average ⌊ Σ(FTₘ+FTEₘ) ÷ 12 ⌋ avg < 50 Not an ALE avg ≥ 50 ALE — § 4980H applies ≥ 130 HOS < 130 HOS < 50 ≥ 50

Step-by-Step Implementation

The annual ALE figure is the floored mean of twelve monthly headcounts, where each monthly headcount combines whole full-time employees with the floored FTE rollup:

FTEm=eNFTmmin(HOSe,m,120)120,ALE avg=112m=112(FTm+FTEm).\text{FTE}_{m} = \left\lfloor \frac{\sum_{e \in \text{NFT}_m} \min(\text{HOS}_{e,m},\,120)}{120} \right\rfloor, \qquad \text{ALE avg} = \left\lfloor \frac{1}{12}\sum_{m=1}^{12}\bigl(\text{FT}_m + \text{FTE}_m\bigr) \right\rfloor.

Step 1 — Classify each employee-month

Apply the 130-hour test exactly. Full-time employees are removed from the FTE pool; only non-full-time hours feed the rollup.

from dataclasses import dataclass
from decimal import Decimal, ROUND_DOWN, getcontext

getcontext().prec = 28  # wide enough that quantize, not context, controls rounding

FULL_TIME_MONTHLY = Decimal("130")
FTE_DIVISOR = Decimal("120")
FTE_HOUR_CAP = Decimal("120")

@dataclass(frozen=True)
class EmployeeMonth:
    employee_id: str
    year: int
    month: int
    hours_of_service: Decimal
    employment_category: str
    jurisdiction: str

def is_full_time(rec: EmployeeMonth) -> bool:
    """26 CFR § 54.4980H-3: full-time = at least 130 HOS in a calendar month."""
    return rec.hours_of_service >= FULL_TIME_MONTHLY

Expected output: is_full_time returns True at exactly Decimal("130") and False at Decimal("129.99") — the boundary is inclusive on 130.

Step 2 — Roll up the monthly FTE count

Cap each non-full-time employee at 120 hours before summing, then divide and floor.

def monthly_fte(records: list[EmployeeMonth]) -> int:
    """FTE for one (year, month): floor(sum(min(hours, 120)) / 120)."""
    capped_total = sum(
        (min(r.hours_of_service, FTE_HOUR_CAP) for r in records if not is_full_time(r)),
        Decimal("0"),
    )
    return int((capped_total / FTE_DIVISOR).to_integral_value(rounding=ROUND_DOWN))

Expected output: three non-full-time employees at 120, 120, and 119.99 hours yield int((Decimal("359.99") / 120)) = 2 — the trailing .99 is correctly truncated, never rounded to 3.

Step 3 — Combine full-time and FTE per month

def monthly_headcount(records: list[EmployeeMonth]) -> int:
    full_time = sum(1 for r in records if is_full_time(r))
    return full_time + monthly_fte(records)

Expected output: 47 full-time employees plus a 2.x FTE rollup returns 49.

Step 4 — Average across the measurement year and apply the ALE gate

Sum twelve monthly headcounts, divide by 12, floor, and compare against 50. Log the determination in structured key=value form so the line is grep-able in production.

import logging

logger = logging.getLogger("aca.fte")

def determine_ale(year_records: dict[int, list[EmployeeMonth]]) -> dict:
    """year_records maps month (1-12) -> that month's employee-month records."""
    monthly = {m: monthly_headcount(year_records.get(m, [])) for m in range(1, 13)}
    annual_total = sum(monthly.values())
    average = (Decimal(annual_total) / Decimal("12")).to_integral_value(
        rounding=ROUND_DOWN
    )
    is_ale = average >= 50

    logger.info(
        "ale_determination year=%s monthly_total=%s avg=%s is_ale=%s",
        next(iter(year_records.values()))[0].year if year_records else "NA",
        annual_total,
        average,
        is_ale,
    )
    return {"monthly": monthly, "average": int(average), "is_ale": is_ale}

Expected output: twelve months summing to 599 give int(599 / 12) = 49 and is_ale=False; a sum of 600 gives 50 and is_ale=True. The boundary is one employee-month wide.

Step 5 — Persist a determination keyed to the stability period

The lookback measurement period produces a status that locks for the corresponding stability period. Write the result to the immutable trail described in Fallback Routing Strategies so a retroactive adjustment routes to an amendment queue rather than overwriting the committed baseline that already drove a 1094-C/1095-C filing.

Verification

Confirm correctness with explicit boundary cases rather than smoke tests. The numbers below are the ones an IRS examiner will probe.

  • 130-hour inclusivity. is_full_time must be True at Decimal("130") and False at Decimal("129.99"). An off-by-one on inclusivity moves a real full-timer into the FTE pool.
  • 120-hour cap before sum. A single employee with 240 HOS contributes 120, not 240, to the rollup. Assert monthly_fte([emp_240]) == 1, not 2.
  • Truncation, not rounding. monthly_fte over hours summing to 359.99 returns 2; over 360.00 returns 3. Replace any round() and the boundary test fails.
  • Annual floor at the ALE line. A twelve-month total of 599 must yield average 49 (is_ale=False); 600 yields 50 (is_ale=True).
  • Decimal-vs-float drift gate. Run the same year through a float shadow path in staging and assert the integer ALE average is identical. Any divergence proves drift and must fail the build.
  • Property test. With hypothesis, generate random non-full-time hour vectors and assert monthly_fte(v) == int(sum(min(h, 120) for h in v) / 120) for all inputs.

Failure Modes

Full-time hours leak into the FTE divisor. Root cause: the rollup sums every record and divides by 120 without first removing employees over the 130-hour line, so full-timers are double-counted (once as whole people, once as fractional FTEs). Remediation: filter on not is_full_time(r) before capping and summing, exactly as in Step 2; add a regression test asserting a fully full-time roster produces monthly_fte == 0.

Seasonal workers inflate the annual average. Root cause: the seasonal-worker exception under 26 CFR § 54.4980H-2(b)(2) — employers may exclude workers employed 120 days or fewer in a year when the only reason they crossed 50 is seasonal labor — is skipped, so a December retail surge triggers a false ALE flag. Remediation: compute cumulative days of employment per worker and apply the exception during annual averaging, while still counting those workers in the monthly FTE rollup for months they were active.

A retroactive adjustment rewrites a filed determination. Root cause: a late timecard correction updates a prior month in place, flipping the locked stability-period status after the 1094-C was already transmitted. Remediation: treat committed determinations as immutable; route corrections through the amendment queue, recompute only forward-looking stability periods, and reconcile via the audit trail rather than mutating history.

External Compliance References