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 INS add (INS03 = 021 or 001) 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 INS cancel (INS03 = 024) with a DTP*349 termination 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.

EDI 834 to payroll deduction reconciliation gate Parsed 834 fields — INS maintenance type, HD plan code, DTP begin and end dates, and the premium amount — map into an expected deduction record. A compare gate checks that record against the actual payroll deduction line for the same member and period. Matches pass through as reconciled; an add with no deduction line raises an enrolled-not-deducted discrepancy, and a term whose deduction line has no stop date raises a termed-still-deducting discrepancy. PARSED 834 SEGMENTS PAYROLL DEDUCTION LEDGER INS HD DTP 348/349 AMT premium maintenance type plan code begin / end date carrier amount Expected deduction member, period, amount compare gate Deduction line actual payroll amount Reconciled amounts and dates tie out enrolled_not_deducted add exists, no deduction line for this member and period termed_still_deducting term date passed, deduction line has no matching stop Every discrepancy carries member_id, plan_code, and both amounts to the cent.
Parsed 834 segments map to an expected deduction, a compare gate checks it against the actual payroll ledger line, and mismatches route to named discrepancy classes rather than silently posting.

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:

  1. 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_id or an unparseable date.
  2. The plan-code-to-deduction-code map is complete and versioned. An HD03 plan 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 an actual ledger entry whose deduction_amount equals the un-prorated period_premium; assert reconcile() returns an empty list.
  • Term stops the deduction. Feed an action_code="024" record with coverage_termination before period_start, and an actual entry with deduction_end set to that same date; assert no termed_still_deducting discrepancy. Remove deduction_end from the same actual entry and assert the discrepancy appears with actual_amount equal to the still-active deduction.
  • Mid-period proration to the cent. With full_period_premium=Decimal("300.00"), a 30-day period, and coverage_effective on day 12, assert prorate_premium(...) == Decimal("190.00") exactly — 19 of 30 days at Decimal("10.00")/day, never a float-derived approximation like 189.99999999.
  • Enrolled-not-deducted detection. Feed an active add with no actual entry at all and assert exactly one Discrepancy with code="enrolled_not_deducted" and expected_amount matching the prorated value.
  • Premium tie-out across a batch. Sum period_premium across every expected record with no discrepancy and assert it equals the sum of deduction_amount in the matching actual ledger entries, to the cent, using Decimal equality 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*348 from the enrollment record, so a retroactive election posts a full, non-prorated premium starting from processing day. Remediation: always derive coverage_effective from the parsed 834 record and run every deduction line through prorate_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 HD03 plan 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 reports enrolled_not_deducted for the entire plan at once. Remediation: treat an unmapped plan_code as 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_deducting but 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.