Reconciling EDI 834 Enrollments to Payroll Deductions
An 834 file can parse perfectly and still leave payroll wrong, because enrollment data and deduction data live in two systems that only agree if a reconciliation stage forces them to. This pattern sits downstream of EDI 834 Parsing, the parent topic within the Multi-Format Payroll Data Ingestion & Normalization framework, and closes the gap between “the carrier says this member is enrolled” and “payroll is actually withholding the right premium every pay period.”
Problem Framing
A validated Enrollment834 record is a statement about coverage, not about payroll. It says a member elected a plan effective on a date, with a premium amount the carrier will bill. It says nothing about whether the payroll system’s deduction table has a matching line, whether that line’s amount agrees to the cent, or whether it started and stopped on the right dates. Three specific failure patterns break naive implementations that treat “parsed” as “reconciled”:
- Silent enrollment without deduction. An
INSadd (INS03 = 021or001) loads cleanly into the eligibility system, but no corresponding deduction line is ever created in payroll — often because the deduction engine’s batch ran before the enrollment file landed, or because a plan-code mapping was missing and the record fell through to a manual queue that no one worked. The member believes they have coverage; the employer is not withholding the premium the carrier will bill for. - Termination that does not stop the deduction. An
INScancel (INS03 = 024) with aDTP*349termination date reaches the eligibility system, but the payroll deduction line has no matching end date, so the deduction keeps firing after coverage ends. This is the more expensive direction of the mismatch: the employee is paying for coverage they no longer have, which is both a wage-and-hour exposure and a refund liability once discovered. - Mid-period effective dates averaged instead of prorated. An election effective on the 12th of a semi-monthly period gets billed as a full-period deduction because the mapping only checks “is this plan active,” not “for how many of this period’s days.” Prorating by calendar fraction — rather than defaulting to a full premium or skipping the period entirely — is the difference between an audit-clean deduction register and a discrepancy the carrier invoice surfaces first.
All three converge on the same requirement: reconciliation must compare the same two decimal-precise numbers — expected premium and actual deduction — for the same effective window, and it must do so as an explicit gate, not an assumption baked into the deduction batch. The Decimal discipline enforced in Enforcing Decimal Precision Across Payroll Fields applies here at the comparison boundary, not only inside the parser.
Prerequisites & Data Requirements
Reconciliation consumes the canonical Enrollment834 records already produced by EDI 834 Parsing — it does not re-parse raw segments. It also requires read access to the current payroll deduction ledger for the same member set and pay period.
| Source | Field | Type | Precondition |
|---|---|---|---|
INS (parsed) |
action_code |
str |
One of 001 (add), 021 (change), 024 (term); other codes were already quarantined upstream. |
HD (parsed) |
plan_code |
str |
Maps 1:1 to a payroll deduction code via the deduction mapping table. |
DTP*348/349 (parsed) |
coverage_effective / coverage_termination |
date / Optional[date] |
Half-open window; effective <= termination when both are present. |
AMT*P3 (parsed) |
premium_amount |
Decimal |
Carrier-billed premium for the full coverage period, never float. |
| Payroll ledger | deduction_amount |
Decimal |
Actual amount currently withheld per pay period for this member and plan code. |
| Payroll ledger | deduction_start / deduction_end |
date / Optional[date] |
Effective window of the deduction line as posted in payroll. |
| Pay calendar | period_start / period_end |
date |
The pay period being reconciled; half-open, matching the convention used for coverage windows. |
Two preconditions sit outside the function signature:
- Enrollment records are already structurally valid. Any 834 segment that failed envelope, loop, or field validation was quarantined before reaching this stage; reconciliation never receives a record with a missing
member_idor an unparseable date. - The plan-code-to-deduction-code map is complete and versioned. An
HD03plan code with no mapping entry is itself a discrepancy —plan_code_unmapped— and must not silently skip the member.
Step-by-Step Implementation
Step 1 — Model the two sides of the comparison. Keep the enrollment-derived expectation and the payroll-ledger actual as separate typed records so a mismatch in either is explicit, never inferred.
from dataclasses import dataclass
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, List, Optional
import logging
logger = logging.getLogger("payroll.edi834.reconcile")
CENTS = Decimal("0.01")
@dataclass(frozen=True)
class ExpectedDeduction:
member_id: str
plan_code: str
action_code: str
coverage_effective: date
coverage_termination: Optional[date]
period_premium: Decimal # prorated for this pay period
@dataclass(frozen=True)
class ActualDeduction:
member_id: str
plan_code: str
deduction_amount: Decimal
deduction_start: date
deduction_end: Optional[date]
@dataclass(frozen=True)
class Discrepancy:
member_id: str
plan_code: str
code: str # enrolled_not_deducted | termed_still_deducting | amount_mismatch
expected_amount: Decimal
actual_amount: Decimal
detail: str
Expected output: three importable dataclasses with no behavior yet; mypy/dataclasses enforce that every field is typed and period_premium cannot be constructed from a float literal without an explicit Decimal(...) call.
Step 2 — Prorate the premium for a mid-period effective date. Convert a full-period carrier premium into the amount owed for the days actually covered within one pay period, using integer day counts under Decimal throughout.
def prorate_premium(
full_period_premium: Decimal,
period_start: date,
period_end: date,
coverage_effective: date,
coverage_termination: Optional[date],
) -> Decimal:
"""Prorate a full-period premium by the fraction of period days covered."""
period_days = Decimal((period_end - period_start).days + 1)
covered_start = max(period_start, coverage_effective)
covered_end = min(period_end, coverage_termination or period_end)
if covered_start > covered_end:
return Decimal("0.00")
covered_days = Decimal((covered_end - covered_start).days + 1)
prorated = (full_period_premium * covered_days / period_days).quantize(
CENTS, rounding=ROUND_HALF_UP
)
logger.info(
"edir_prorate covered_days=%s period_days=%s prorated=%s",
covered_days, period_days, prorated,
)
return prorated
Expected output: a full-period premium of Decimal("300.00") over a 30-day period, with coverage_effective on day 12, returns Decimal("190.00") (19 of 30 days).
Step 3 — Build the expected deduction from parsed 834 fields. Apply the maintenance type so an add or change produces a positive expectation and a term produces a zero expectation once the termination date is behind the period.
def build_expected_deduction(
member_id: str,
plan_code: str,
action_code: str,
coverage_effective: date,
coverage_termination: Optional[date],
full_period_premium: Decimal,
period_start: date,
period_end: date,
) -> ExpectedDeduction:
"""Translate one 834 enrollment event into an expected per-period deduction."""
if action_code == "024" and coverage_termination and coverage_termination < period_start:
period_premium = Decimal("0.00")
else:
period_premium = prorate_premium(
full_period_premium, period_start, period_end,
coverage_effective, coverage_termination,
)
return ExpectedDeduction(
member_id=member_id,
plan_code=plan_code,
action_code=action_code,
coverage_effective=coverage_effective,
coverage_termination=coverage_termination,
period_premium=period_premium,
)
Expected output: for action_code="024" with coverage_termination two periods in the past, period_premium == Decimal("0.00").
Step 4 — Reconcile expected against actual and emit named discrepancies. Compare to the cent; never round the actual side to match the expected side or vice versa.
def reconcile(
expected: List[ExpectedDeduction],
actual: Dict[str, ActualDeduction], # keyed by f"{member_id}:{plan_code}"
) -> List[Discrepancy]:
"""Compare expected 834-derived deductions against the actual payroll ledger."""
discrepancies: List[Discrepancy] = []
for exp in expected:
key = f"{exp.member_id}:{exp.plan_code}"
act = actual.get(key)
if exp.period_premium > Decimal("0.00") and act is None:
discrepancies.append(Discrepancy(
exp.member_id, exp.plan_code, "enrolled_not_deducted",
exp.period_premium, Decimal("0.00"),
"active enrollment has no matching deduction line",
))
logger.warning(
"edir_discrepancy code=enrolled_not_deducted member=%s plan=%s expected=%s",
exp.member_id, exp.plan_code, exp.period_premium,
)
continue
if exp.period_premium == Decimal("0.00") and act is not None and act.deduction_end is None:
discrepancies.append(Discrepancy(
exp.member_id, exp.plan_code, "termed_still_deducting",
exp.period_premium, act.deduction_amount,
"coverage termed but deduction line has no stop date",
))
logger.warning(
"edir_discrepancy code=termed_still_deducting member=%s plan=%s actual=%s",
exp.member_id, exp.plan_code, act.deduction_amount,
)
continue
if act is not None and act.deduction_amount != exp.period_premium:
discrepancies.append(Discrepancy(
exp.member_id, exp.plan_code, "amount_mismatch",
exp.period_premium, act.deduction_amount,
"deduction amount does not tie to prorated premium",
))
logger.warning(
"edir_discrepancy code=amount_mismatch member=%s plan=%s expected=%s actual=%s",
exp.member_id, exp.plan_code, exp.period_premium, act.deduction_amount,
)
return discrepancies
Expected output: an add with period_premium=Decimal("190.00") and no matching actual entry yields one Discrepancy with code="enrolled_not_deducted".
Step 5 — Run reconciliation for one pay period. Wire the steps together against a small in-memory ledger to confirm the full path end to end.
expected_records = [
build_expected_deduction(
member_id="M-7734", plan_code="MED-PPO", action_code="021",
coverage_effective=date(2026, 7, 12), coverage_termination=None,
full_period_premium=Decimal("300.00"),
period_start=date(2026, 7, 1), period_end=date(2026, 7, 15),
),
]
actual_ledger = {} # no deduction line posted yet
results = reconcile(expected_records, actual_ledger)
# results[0].code == "enrolled_not_deducted"
# results[0].expected_amount == Decimal("126.67")
Verification
- Add creates a deduction. Feed an
action_code="021"record with a full-period effective date and anactualledger entry whosededuction_amountequals the un-proratedperiod_premium; assertreconcile()returns an empty list. - Term stops the deduction. Feed an
action_code="024"record withcoverage_terminationbeforeperiod_start, and anactualentry withdeduction_endset to that same date; assert notermed_still_deductingdiscrepancy. Removededuction_endfrom the sameactualentry and assert the discrepancy appears withactual_amountequal to the still-active deduction. - Mid-period proration to the cent. With
full_period_premium=Decimal("300.00"), a 30-day period, andcoverage_effectiveon day 12, assertprorate_premium(...) == Decimal("190.00")exactly — 19 of 30 days atDecimal("10.00")/day, never afloat-derived approximation like189.99999999. - Enrolled-not-deducted detection. Feed an active add with no
actualentry at all and assert exactly oneDiscrepancywithcode="enrolled_not_deducted"andexpected_amountmatching the prorated value. - Premium tie-out across a batch. Sum
period_premiumacross everyexpectedrecord with no discrepancy and assert it equals the sum ofdeduction_amountin the matchingactualledger entries, to the cent, usingDecimalequality rather than a tolerance band.
Failure Modes
- Deduction created from the load date instead of the coverage-effective date. Root cause: the deduction engine’s batch job stamps a new deduction line with today’s date rather than reading
DTP*348from the enrollment record, so a retroactive election posts a full, non-prorated premium starting from processing day. Remediation: always derivecoverage_effectivefrom the parsed 834 record and run every deduction line throughprorate_premium()against the pay period it lands in, never the batch run date. - Plan-code drift between carrier and payroll. Root cause: the carrier renames or splits an
HD03plan code and the deduction mapping table is not updated in the same release, so every affected member’s expected deduction silently maps to nothing and reconciliation reportsenrolled_not_deductedfor the entire plan at once. Remediation: treat an unmappedplan_codeas its own discrepancy class (plan_code_unmapped) surfaced separately from per-member mismatches, and route it to Fallback Routing for Unclassified Deductions rather than letting it look like a batch of individual member errors. - Termination processed after the deduction batch already ran. Root cause: the 834 termination file arrives after payroll’s deduction cutoff for the current period, so the member is correctly flagged as
termed_still_deductingbut the fix cannot land until the next cycle, and without a carry-forward refund flag the overage is never returned. Remediation: when a termination discrepancy is detected for a period that already disbursed, emit a refund-eligible flag alongside the discrepancy so a subsequent pay period’s off-cycle adjustment run picks it up automatically instead of waiting for a manual complaint.
Frequently Asked Questions
Why compare on the pay period instead of comparing the raw carrier premium directly?
Because the carrier premium in AMT*P3 is typically a full-period or full-month figure, while payroll withholds per pay period, and enrollments frequently start or end mid-period. Comparing the raw carrier amount against a per-period deduction line without prorating first produces a permanent mismatch on every mid-period enrollment or termination, even when payroll is doing the right thing. Prorating both sides to the same period boundary before comparing is what makes the discrepancy signal trustworthy.
What should happen when a member has two active plans with the same plan code from different effective dates?
Reconcile against the coverage rule in force for the pay period’s dates, using the same half-open effective-dating logic described in EDI 834 Parsing: the version whose window contains the period date wins, and if two versions both claim the same date the rule set should have already failed to load rather than reaching reconciliation. Reconciliation should never receive two overlapping active records for the same member and plan code.
Should reconciliation auto-correct the payroll deduction line when it finds a discrepancy?
No. Reconciliation’s job is detection, not remediation — it emits a typed Discrepancy with the member, plan, and both amounts so a downstream correction workflow or a human reviewer can act with full context. Auto-writing a corrected deduction amount from inside the compare gate removes the audit trail that shows a discrepancy existed at all, which is the opposite of what a carrier or DOL audit needs.
How far back should a termed-still-deducting check look?
At minimum, back to the earliest pay period that could still be within a refund or true-up window under the plan’s terms, not just the current period. A termination processed today can reveal that a deduction kept running for several prior periods, and each of those periods needs its own discrepancy record with its own expected and actual amounts so the refund calculation is built from a complete, period-by-period ledger rather than a single lump-sum guess.
Related
- Parsing EDI 834 Files with Python — the implementation walkthrough that produces the canonical enrollment records this stage consumes.
- EDI 834 Parsing — the parent topic covering envelope validation, loop-state assembly, and effective-dated coverage resolution.
- Ordering Pre-Tax 401(k) and Section 125 Deductions — the deduction-sequencing rules that apply once a premium deduction is confirmed reconciled.
- Deduction Mapping Rules — the topic covering how plan and garnishment codes map to payroll deduction lines and general-ledger accounts.