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:
- 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.
- 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 |
1–12; 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, notfloat. 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/monthis 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.
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:
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_timemust beTrueatDecimal("130")andFalseatDecimal("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, not2. - Truncation, not rounding.
monthly_fteover hours summing to359.99returns2; over360.00returns3. Replace anyround()and the boundary test fails. - Annual floor at the ALE line. A twelve-month total of
599must yield average49(is_ale=False);600yields50(is_ale=True). - Decimal-vs-float drift gate. Run the same year through a
floatshadow 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 assertmonthly_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.
Related
- ACA Tracking Logic — the parent determination engine: HOS normalization and lookback/administrative/stability measurement windows that feed this rollup.
- Mapping FLSA thresholds for multi-state payroll — effective-dated exempt/non-exempt resolution for the same workforce.
- Currency conversion in multi-state payroll — Decimal-precision boundaries applied to a different cross-jurisdiction field.
- Fallback routing for unclassified deductions — quarantine and review-queue patterns reused for ambiguous ACA records.