Weighted-Average Regular Rate with Nondiscretionary Bonuses: Retroactive Overtime Recomputation
When a quarterly production bonus, an attendance bonus, or a safety bonus is paid out after the workweeks it covers have already closed and been disbursed, an engine that treats the bonus as a flat, standalone payment underpays every overtime hour in that period, because the bonus itself raises the regular rate those hours should have been paid against. This pattern sits inside Overtime Calculation Engines, the parent topic within the Payroll Calculation Engines & Validation Rules framework, and it isolates the bonus-allocation and retroactive-recompute stage as its own deterministic job so a closed pay period can be reopened, corrected, and re-disbursed without touching unrelated payroll runs.
Problem Framing
Under 29 CFR § 778.208, nondiscretionary bonuses — production bonuses, attendance bonuses, safety bonuses, and any bonus promised or announced in advance to induce performance — must be included in the regular rate of pay used to compute overtime. A bonus is discretionary, and therefore excluded, only when both the fact and the amount of payment remain in the employer’s sole discretion until at or near the end of the period; a posted production-incentive schedule or an attendance policy with a fixed payout amount is not discretionary no matter what the earnings statement calls it.
The complication that breaks naive implementations is temporal, not arithmetic. A single-workweek bonus is easy: fold it into that week’s regular rate before multiplying overtime hours, exactly as the parent engine’s production pattern already does. This scenario is different — the bonus is earned across several workweeks (a monthly attendance bonus, a quarterly production bonus) but paid once, after those weeks have already been processed and the overtime on them already disbursed at the pre-bonus rate. 29 CFR § 778.209 requires the bonus to be allocated back across the workweeks in which it was earned, each affected week’s regular rate recomputed with its share of the bonus folded in, and additional overtime compensation paid on every overtime hour in those weeks — retroactively, after the fact. This is distinct from blended-rate overtime, which reconciles multiple hourly rates worked within a single, still-open workweek; see Blended-Rate Overtime for Multiple Pay Rates for that case. Here there is only one rate per week, but the bonus reaches backward across many already-closed weeks.
One exception matters at the boundary: under 29 CFR § 778.210, a bonus paid as a fixed percentage of an employee’s total straight-time and overtime earnings for the period requires no recompute at all, because it already increases the overtime pay component proportionally by construction. An engine that runs the full retroactive-recompute pipeline against a percentage-of-total-earnings bonus overpays; the allocation stage must classify the bonus type before doing any work.
Prerequisites & Data Requirements
The retroactive recompute stage needs both the bonus record and the closed workweek records it covers. Every monetary and hour field is Decimal; the recompute touches weeks that have already been paid, so a float-induced cent drift here reopens a paycheck that was previously correct.
| Field | Type | Precondition |
|---|---|---|
bonus_id |
str |
Stable identifier for the audit trail linking bonus to affected weeks. |
bonus_amount |
Decimal |
Total nondiscretionary bonus paid; never a float. |
is_discretionary |
bool |
True bonuses are rejected before allocation per 29 CFR § 778.211. |
is_percentage_of_total_earnings |
bool |
True routes to the § 778.210 exception — no recompute performed. |
covered_weeks |
list[WorkWeek] |
Every already-closed workweek the bonus period spans, in chronological order. |
week.hours_worked |
Decimal |
Total hours worked that week, including overtime hours. |
week.overtime_hours |
Decimal |
Hours already paid at 1.5× before the bonus existed. |
week.base_hourly_rate |
Decimal |
The straight-time rate used for the original disbursement. |
Two preconditions sit outside the function signature:
- The covered-weeks list must match the bonus’s actual earning period exactly, not the pay period in which the bonus happens to be disbursed. A quarterly production bonus paid in the first week of the next quarter still covers the thirteen prior weeks — not the week it lands in.
- Discretionary status is decided before allocation runs, using the same fixed-in-advance test Overtime Calculation Engines applies to shift differentials: if the amount or the fact of payment was ever contingent on the employer’s unannounced discretion, the bonus is excluded and the pipeline stops.
Step-by-Step Implementation
Step 1 — Model the bonus and the affected weeks. Freeze both as dataclasses so the recompute is reproducible and the audit trail carries typed fields end to end.
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import List, Tuple
import logging
logger = logging.getLogger("payroll.overtime.bonus_retro")
RATE_Q = Decimal("0.0001")
CENTS = Decimal("0.01")
HALF = Decimal("0.5")
@dataclass(frozen=True)
class WorkWeek:
week_ending: str # ISO date, e.g. "2026-04-04"
hours_worked: Decimal
overtime_hours: Decimal # already paid at 1.5x before this bonus existed
base_hourly_rate: Decimal
@dataclass(frozen=True)
class NondiscretionaryBonus:
bonus_id: str
amount: Decimal
is_discretionary: bool
is_percentage_of_total_earnings: bool
covered_weeks: List[WorkWeek]
@dataclass
class RetroAdjustment:
week_ending: str
allocated_bonus: Decimal
prior_regular_rate: Decimal
recomputed_regular_rate: Decimal
additional_ot_owed: Decimal
Step 2 — Allocate the bonus across the covered weeks. Reject discretionary bonuses and the § 778.210 percentage-of-total-earnings case before doing any allocation work; only a true nondiscretionary bonus tied to a fixed dollar amount reaches equal allocation. Expected output for $600.00 across 4 weeks: Decimal("150.0000") per week.
def allocate_bonus_across_weeks(bonus: NondiscretionaryBonus) -> Decimal:
"""Allocate a nondiscretionary bonus equally across its covered weeks."""
if bonus.is_discretionary:
raise ValueError(
f"bonus={bonus.bonus_id} is discretionary — excluded per 29 CFR § 778.211"
)
if bonus.is_percentage_of_total_earnings:
raise ValueError(
f"bonus={bonus.bonus_id} is a percentage-of-earnings bonus — "
"29 CFR § 778.210 requires no recompute"
)
n_weeks = len(bonus.covered_weeks)
if n_weeks == 0:
raise ValueError(f"bonus={bonus.bonus_id} has no covered weeks")
per_week = (bonus.amount / Decimal(n_weeks)).quantize(RATE_Q, ROUND_HALF_UP)
logger.info(
"bonus_allocate id=%s amount=%s weeks=%s per_week=%s",
bonus.bonus_id, bonus.amount, n_weeks, per_week,
)
return per_week
Step 3 — Recompute one week’s regular rate and incremental overtime. Fold the allocated share into that week’s straight-time earnings, divide by hours worked, and pay half-time — not full time-and-a-half — on the overtime hours already in that week. Expected output for week 2 (46 hours, 6 OT hours, $18.00 base, $150.00 allocated): recomputed_regular_rate = Decimal("21.2609"), additional_ot_owed = Decimal("9.78").
def recompute_week(week: WorkWeek, allocated_bonus: Decimal) -> RetroAdjustment:
"""Recompute one closed workweek's regular rate with its allocated bonus share."""
if week.hours_worked <= Decimal("0"):
raise ValueError(f"week={week.week_ending} has non-positive hours_worked")
straight_time_pay = week.hours_worked * week.base_hourly_rate
prior_rate = (straight_time_pay / week.hours_worked).quantize(RATE_Q, ROUND_HALF_UP)
recomputed_pay = straight_time_pay + allocated_bonus
recomputed_rate = (recomputed_pay / week.hours_worked).quantize(RATE_Q, ROUND_HALF_UP)
bonus_per_hour = (allocated_bonus / week.hours_worked).quantize(RATE_Q, ROUND_HALF_UP)
additional_ot = (bonus_per_hour * HALF * week.overtime_hours).quantize(CENTS, ROUND_HALF_UP)
logger.info(
"week_recompute week=%s alloc=%s prior_rate=%s new_rate=%s ot_hours=%s additional_ot=%s",
week.week_ending, allocated_bonus, prior_rate, recomputed_rate,
week.overtime_hours, additional_ot,
)
return RetroAdjustment(
week.week_ending, allocated_bonus, prior_rate, recomputed_rate, additional_ot
)
The additional overtime owed on each affected week follows directly from the bonus-adjusted regular rate. For workweek with allocated bonus share and hours worked :
Because the employee already received full straight-time pay for every hour, including the overtime hours, the retroactive amount owed is only the additional half-time premium on the overtime hours already worked that week:
Step 4 — Sum the retro adjustments for the whole bonus period. Round only at the per-week cent boundary; sum the already-rounded weekly amounts so the total reconciles line-for-line with the audit log.
def compute_retro_overtime(
bonus: NondiscretionaryBonus,
) -> Tuple[Decimal, List[RetroAdjustment]]:
"""Compute total retroactive overtime owed across a bonus's covered weeks."""
per_week = allocate_bonus_across_weeks(bonus)
adjustments = [recompute_week(w, per_week) for w in bonus.covered_weeks]
total = sum(
(a.additional_ot_owed for a in adjustments), Decimal("0")
).quantize(CENTS, ROUND_HALF_UP)
logger.info(
"bonus_retro_total id=%s weeks=%s total_additional_ot=%s",
bonus.bonus_id, len(adjustments), total,
)
return total, adjustments
Step 5 — Run the pipeline against a real quarterly production bonus. A $600.00 production bonus earned across four workweeks, disbursed after two of those weeks already carried overtime hours:
bonus = NondiscretionaryBonus(
bonus_id="BON-2026Q2-0091",
amount=Decimal("600.00"),
is_discretionary=False,
is_percentage_of_total_earnings=False,
covered_weeks=[
WorkWeek("2026-04-04", Decimal("40"), Decimal("0"), Decimal("18.00")),
WorkWeek("2026-04-11", Decimal("46"), Decimal("6"), Decimal("18.00")),
WorkWeek("2026-04-18", Decimal("40"), Decimal("0"), Decimal("18.00")),
WorkWeek("2026-04-25", Decimal("44"), Decimal("4"), Decimal("18.00")),
],
)
total, adjustments = compute_retro_overtime(bonus)
# total == Decimal("16.60")
# adjustments[0].additional_ot_owed == Decimal("0.00") # week 1, no OT hours
# adjustments[1].additional_ot_owed == Decimal("9.78") # week 2, 6 OT hours
# adjustments[2].additional_ot_owed == Decimal("0.00") # week 3, no OT hours
# adjustments[3].additional_ot_owed == Decimal("6.82") # week 4, 4 OT hours
Verification
Confirm correctness against the specific properties of the bonus-retro recompute, not just the summed total:
- Bonus raises the regular rate. For any week with
allocated_bonus > 0, assertrecomputed_regular_rate > prior_regular_rate. In the example, week 2’s rate moves fromDecimal("18.0000")toDecimal("21.2609"). - Retro overtime owed only on overtime weeks. Assert
additional_ot_owed == Decimal("0.00")for every week whereovertime_hours == Decimal("0"), even though the bonus still raised that week’s regular rate. Weeks 1 and 3 in the example receive no additional payment; the bonus itself, already disbursed, is their only compensation. - Discretionary bonus excluded before allocation. Call
allocate_bonus_across_weekswithis_discretionary=Trueand assert aValueError— the pipeline must reject the bonus at the boundary rather than fold a true discretionary payment into the regular rate. - Decimal determinism. Run
compute_retro_overtimeagainst the sameNondiscretionaryBonusthree times and assert byte-identicaltotaland per-weekadditional_ot_owedvalues. Any drift means afloatleaked into the allocation or rate path. - Percentage-of-earnings exception. Call with
is_percentage_of_total_earnings=Trueand assert aValueErrorrather than a recomputed total — that bonus type is handled entirely outside this pipeline per § 778.210.
Failure Modes
- Bonus excluded from the regular rate entirely. Root cause: the bonus is disbursed through a separate “extra pay” earning code that never reaches the overtime engine, so the regular rate used for the original weeks — and any later audit — never reflects it. Remediation: route every nondiscretionary bonus through the same classification gate the overtime engine uses for shift differentials, and fail the payroll run if a bonus record has no
covered_weeksto allocate against. - No retroactive recompute performed. Root cause: the bonus is treated as a forward-looking, single-transaction payment, so it is added to gross pay in the week it is disbursed but the overtime hours in the weeks it was actually earned are never revisited. Remediation: trigger
compute_retro_overtimefor the bonus’s entire covered period as part of bonus disbursement, and require aretro_adjustment_idon every affected week’s record before the bonus run closes. - Allocating the bonus to the wrong weeks. Root cause: the allocation uses the disbursement pay period, or the calendar month the bonus check was cut, instead of the weeks the bonus was actually earned — a quarterly bonus paid in week 14 gets allocated to week 14 alone instead of weeks 1 through 13. Remediation: require
covered_weeksto be supplied explicitly from the bonus-earning schedule, never inferred from the disbursement date, and reject a bonus record with an empty or single-week coverage list when the bonus policy defines a longer earning period.
Frequently Asked Questions
Why is only half-time owed retroactively, not the full time-and-a-half?
Because the employee already received straight-time pay for every hour worked that week, including the overtime hours, at the original rate — and the original 1.5× overtime payment already covered the “half” premium on the base rate. Once the bonus is folded in, the regular rate rises, so only the additional half-time premium attributable to that rate increase is still owed on the overtime hours. Paying the full 1.5× again on the bonus-adjusted rate would double-pay the straight-time portion.
How should the bonus be allocated when workweeks have unequal hours?
Equal per-week allocation, as used here, is the fallback method 29 CFR § 778.209 permits when the actual amount attributable to each week cannot be determined. When the bonus is tied to measurable weekly output or earnings — a per-unit production bonus, for example — the employer may instead allocate in proportion to hours worked or earnings in each week, which better reflects where the bonus was actually earned. Either method must be applied consistently and documented, since the two can produce different per-week regular rates.
Does this recompute apply to discretionary bonuses like a holiday gift or a spot bonus?
No. A bonus is discretionary, and excluded from the regular rate under 29 CFR § 778.211, only when both the fact and the amount of payment remain in the employer’s sole discretion until at or near the end of the period. A holiday gift of a fixed amount announced in advance, or a spot bonus paid under a standing policy, is nondiscretionary despite the informal label and must go through this same allocation and recompute pipeline.
What if the bonus cannot be attributed to specific workweeks at all?
If the bonus period genuinely cannot be tied to identifiable workweeks, 29 CFR § 778.209 permits allocating it equally to each week of the employment period during which it was earned, which is the equal-per-week method implemented above. What is never acceptable is skipping allocation entirely and treating the bonus as if it were paid outside the regular-rate calculation — the recompute obligation exists regardless of how coarse the allocation method has to be.
Related
- Blended-Rate Overtime for Multiple Pay Rates — the single-workweek, multiple-hourly-rate case this bonus-retro pattern is often confused with.
- Calculating Double Overtime for California — daily and seventh-day premium tiers that also consume a bonus-adjusted regular rate.
- Overtime Calculation Engines — the parent topic defining threshold resolution and the regular-rate formula this page extends.
- Backtesting Threshold Changes Against Historical Runs — replaying historical pay periods, the same discipline this retro recompute depends on.