[002] engineering

Building self-correcting payroll deductions

mateo.paredes.sepulveda8min

At Thatch, an employer gives their employees an allowance, and employees go pick whichever health insurance plan fits them best. When the plan costs more than the allowance, the difference gets deducted from their paycheck. This concept seemed simple enough to us when we built Thatch's ICHRA offering in 2023, but it's turned out to be a complex and fun engineering challenge. Payroll deductions are hard.

Payroll deductions are interesting and hard

Payroll deductions aren't like systems where a bug is annoying but recoverable. These are the amounts that come out of someone's paycheck before they pay rent or buy groceries.

With ICHRA, getting it right is a lot harder than it sounds. Every employee is on a different plan at a different price, employers can have classes of employees on different pay schedules, insurance plan premiums and allowances change mid-year, and coverage dates get corrected retroactively. The inputs we use to compute a payroll deduction aren't static.

It’s a problem that we can’t ask humans to manage as we grow. We've spent the last few years building a system that handles this at scale with less human intervention. One that self-corrects when underlying data changes, stays as consistent as possible from paycheck to paycheck, and flags the cases it can't handle automatically.

The attributes every payroll system should aspire to

Our system needs to do three things right:

  1. Compute the total deduction for each employee through their plan's coverage period.
  2. Compute the per-paycheck amount given the employer's specific pay schedule.
  3. Handle updates as the underlying data changes mid-year, applying corrections in a way that's legally compliant and doesn't produce jarring spikes in someone's paycheck.

That third one is where the most interesting engineering is. We have an ethical obligation to each Thatch member to get their deduction right, but we're also constrained by a tax compliance requirement that dictates how we can actually compute and administer these deductions.

A brief tax law detour

Section 125 is a part of the U.S. tax code that lets employees pay health insurance premiums with pre-tax dollars. Thatch uses this section of the tax code to facilitate pre-tax deductions for employees, reducing the net contribution toward their insurance plan.

For that tax treatment to apply, the deduction amounts have to be determined at the start of the plan year and stay consistent. Adjustments are allowed, but only for administrative corrections (e.g. an employee got their child’s age wrong, leading to an incorrect plan premium). The constraint is meant to protect against arbitrary fluctuation: deductions can't just drift from paycheck to paycheck. Employers face potential compliance exposure if deductions don't follow these rules.

That constraint becomes a core architectural invariant for the whole system: whatever happens to the underlying data, the total deducted by end of year has to match the elected amount, and the path there should be as smooth as possible. The interesting engineering challenge is maintaining that when the underlying data keeps changing.

Our payroll math happy path

We call the base unit of our deduction calculation a “span”: a contiguous period of coverage where an employee's plan and allowance are the same. A span starts when an employee enrolls and ends when something structurally changes (switching plans, adding a dependent, going through a qualifying life event, or moving).

In our happy path, the computation is actually straightforward:

# Find the span:
months_in_span = get_months_of_contiguous_coverage(current_month)

# Per month in a span:
monthly_deduction = monthly_premium - monthly_allowance

# Across the full span:
span_total = monthly_deduction * months_in_span

# Per paycheck:
per_period_deduction = span_total / pay_periods_in_span

Consider this concrete example:

  • An employee enrolls January 1.
  • Each month, their plan costs $500 and their allowance is $300.
  • So, their monthly deduction is $200. Over the year that's $2,400. This is the elected Section 125 amount.
  • Paid bi-weekly across 26 pay periods, each paycheck’s deduction is roughly $92.

A hard part, knowing when employers actually pay

Every deduction calculation depends on one critical input: the employer's pay schedule (weekly, biweekly, semi-monthly, monthly). It determines how many paychecks fall in a given span, that’s pay_periods_in_span in the example above.

Getting this wrong can be very bad. If we're off by even a single pay period, the denominator in the calculation can change for that employer's entire employee population, resulting in incorrect deductions for everyone.

Collecting the employer’s pay schedule

Employers are asked to input their pay schedule when they set up their Thatch account. In practice, the pay schedule can be filled in incorrectly. Fortunately, employers can elect to integrate Thatch with their HRIS or payroll system to reduce their benefits administration work. Having access to a payroll system generally includes visibility into an employer’s pay history.

This allows us to reduce error. When the integration provides pay schedule data directly, we use it. When it doesn't, which is far more common, we fall back to inferring the schedule from payment history:

# Group payments by shared employee overlap (≥80%)
schedule_groups = payments.group_by_overlap(threshold: 0.8)

# Infer frequency from median gap between payments in each group
schedule_groups.map do |group|
  gaps = group.payment_dates.each_cons(2).map { |a, b| (b - a).to_i }
  median_gap = gaps.sort[gaps.size / 2]

  frequency = case median_gap
    when ..7    then :weekly
    when 13...15 then :biweekly
    when 28..   then :monthly
    else             :semi_monthly
    end

  PaySchedule.new(frequency:, employees: group.employee_ids)
end

The employer is still involved, but their work becomes confirming their schedule instead of inputting it from scratch.

Banks take time off too

Banks are closed on weekends and federal holidays. When a pay date falls on a non-business day, the employer has to move it, pushing the pay date to the closest business day before or after. This is particularly tricky during year boundaries, where an incorrectly handled pay date that lands on January 1st can be pushed into the next year, reducing the denominator for the employer’s ongoing year of coverage.

If we’re careful, we can just detect how an employer handles bank closures through their pay history. Once we know an employer's basic pay schedule attributes, we enumerate what every past pay date should have been and look for cases where projected dates would have landed on non-business days. Then, we compare against the actual pay dates we see in the history, and are able to identify if an employer pushed their pay dates back or forward due to weekends or holidays.

# Build projected pay dates backward from anchor
projected_dates = generate_dates(anchor_date, cadence, direction: :backward)

# For each projected date that falls on a non-business day:
holiday_cases = projected_dates.select { |d| !business_day?(d) }

# Find the nearest actual payment and note which side it landed on
results = holiday_cases.map do |projected|
  actual = nearest_payment(projected)
  actual < projected ? :before : :after
end

strategy = results.count(:before) >= results.size * 0.5 ? :before : :after

Another hard part, premium and allowance changes mid-year

Grouping deductions into contiguous periods of coverage naturally handles structural changes in an employee’s plan. A new plan or allowance means a new span is created, and a fresh deduction is computed. But there's a more common class of problem: the underlying data gets adjusted after we've already used it in past calculations, requiring a deduction change without generating a new span.

Coverage start dates occasionally need correction, which changes a span's length. Thatch also tracks every premium payment we make on behalf of employees, which lets us detect when a carrier bills a different amount than what was quoted. None of these trigger a new span, since the employee is on the same plan.

To handle this, we built toward eventual correctness. If we immediately corrected each deduction to account for past errors, employees could see large sudden spikes on a single paycheck. Instead, when we detect a correction, we recalculate forward without unwinding what's already gone out:

# Something changed — within an existing span
# Recompute what the span total should have been                                                                            
new_span_total = recompute_span_total(span, updated_data)
                                                                                                                              
# How much has already been deducted year-to-date               
ytd_deducted = sum_deductions_ytd(member, span)
                                                                                                                              
# Distribute the remaining amount across future pay periods in the span
remaining = new_span_total - ytd_deducted                                                                             

per_period_deduction = remaining / remaining_pay_periods_in_span

Take the example we looked at earlier. Perhaps in March, we discover the carrier was billing $510 per month, not $500.

  • The new span total is $2,520.
  • Through February, we've already deducted 4 paychecks at $92.31 each. That's $369 year-to-date.
  • That leaves $2,151 to spread across 22 remaining pay periods.
  • Each paycheck adjusts from $92 to roughly $98. About $6 more per check through year-end.

So where do we go from here?

Payroll deductions sit at an intersection of tax compliance, messy real-world insurance data, lots of math, and systems that have to be reactive and stable at the same time. The last few years have been about getting to a place where we can trust the calculation, putting automated review processes in place to ensure anomalies don’t become real issues that impact employees. We're not done, but this system is in a fundamentally better place than it was when we started, and better is always good.

A few things we’re working on

Intentional change control. Right now, our system reacts to most upstream changes quickly. A small premium rounding difference triggers the same recalculation as a material allowance update. We're working toward batching changes and applying materiality thresholds, so corrections propagate at a more regular cadence, rather than the moment they arrive. We’ll maintain the same eventual correctness with less noise.

Self-serve corrections for employers. Today, if an employer realizes they processed the wrong deduction for a pay period, that requires a support ticket to fix. We want them to be able to flag it themselves and have the system automatically calculate and apply the correction.

Automated anomaly detection. Given an integration with an employer’s HRIS, we can start proactively scanning deduction history to surface things that look off. If an employer's been processing wrong amounts for two months, we should be the ones noticing.