Rebate Accrual Reconciliation

Rebate accrual reconciliation is the periodic accounting discipline that keeps a PBM’s estimated rebate receivable honest against the cash a manufacturer actually invoices and pays. Every adjudicated claim that touches a rebate-eligible product generates an estimated receivable the moment it clears — a number derived from contract terms, not from money in the bank. Weeks or months later the manufacturer issues an invoice, disputes some lines, and remits partial cash. The gap between what was accrued and what was collected is the reconciliation problem: it has to be measured at a defined grain, aged, bucketed into variance categories, and closed out with an auditable true-up so the general ledger reflects economic reality rather than an optimistic estimate. This area specifies the accrual ledger, the state machine that governs each receivable, and the Python engine that reconciles the two sides idempotently across period boundaries, extending the financial subsystem introduced in Rebate Calculation & Accrual Tracking.

Problem Framing

An accrual is a promise the ledger makes to itself. When a claim for a rebate-eligible drug adjudicates, the engine books a receivable equal to the contracted rebate the manufacturer should owe on that unit of utilization. That estimate is only as good as the contract model and the spread math behind it, and it is booked long before any invoice exists. Reconciliation is the closing of that loop: for each contract, product family, and general-ledger period, compare the sum of accrued estimates against the amount the manufacturer invoiced and the cash ultimately collected, then resolve the difference. Get this wrong and the financial statements carry a phantom asset — receivables that will never convert to cash — or, worse, a silent understatement that surfaces as a restatement during a payer audit. The reconciliation engine sits downstream of accrual booking and upstream of the true-up posting, and like every other stage in the claims path it must be deterministic, replayable against versioned snapshots, and free of protected health information in every log line and every stored artifact.

The unit of reconciliation is deliberately coarser than the claim. A single manufacturer invoice settles thousands of accrued claim-level receivables at once, so the ledger aggregates accruals to the reconciliation grain — typically contract by Generic Product Identifier (GPI) family by period — before matching. That aggregation is where most reconciliation defects are born: a grain mismatch between how accruals were keyed and how the invoice is structured produces variances that are pure artifacts of bucketing, not genuine economic gaps.

Prerequisites

The reconciliation engine is a consumer of several upstream guarantees. Before it runs a period close, the following must already hold:

  • Accrual records keyed to adjudicated claims. Each accrual carries an immutable link to the claim that generated it — the tokenized 402-D2 Prescription/Service Reference # — plus the 407-D7 Product/Service ID (NDC), its resolved GPI, the 442-E7 Quantity Dispensed as decimal.Decimal, and the rebate amount computed at adjudication time. The reconciliation engine never re-reads the raw claim; it operates on the accrual record, which is already PHI-tokenized. The estimate itself comes from Manufacturer Rebate Contract Modeling applied to utilization.
  • Contract snapshots, version-stamped. A rebate contract is an immutable dated snapshot (contract.version = "v2026.02"), never a mutable row. The rebate an accrual references must be the one in force at the claim’s dispensing date, so a mid-quarter amendment cannot silently reprice a receivable that was already booked. This is the same versioned-snapshot discipline that makes any adjudication replayable during an examination.
  • General-ledger period boundaries. Reconciliation is scoped to a closed GL period. The engine needs an authoritative period calendar — the exact UTC cutoff timestamps that separate one accounting period from the next — so that a claim adjudicated at 23:59 on the last day of a month accrues into that month and not the next. A fuzzy period boundary is the single most common source of timing variance.
  • A settled spread basis. The estimated receivable depends on the WAC/AMP Spread Calculation that priced the underlying unit. The spread basis in force at accrual time is captured on the accrual record so reconciliation compares like with like even after published WAC or AMP values are restated.
  • An append-only audit sink. Every reconciliation decision — every state transition, every variance classification, every true-up — serializes to the same event store described in Security & Compliance Boundaries for Claims Data before any GL entry is posted.

Reconciliation State Model

Each aggregated receivable moves through a small, explicit state machine. The states are not free-form status strings; they are the vocabulary the engine, the GL, and the auditor share. A receivable is ACCRUED when the estimate is booked, INVOICED when the manufacturer’s statement arrives, PARTIALLY_COLLECTED when cash lands short of the invoice, DISPUTED when the variance exceeds tolerance and is escalated, and TRUED_UP when a closing adjustment reconciles the accrual to the collected amount and the receivable is retired for the period.

State Meaning Entry trigger Money basis Legal exits
ACCRUED Estimated receivable booked from adjudicated utilization Claim clears with a rebate-eligible GPI accrued_amount (Decimal) INVOICED, TRUED_UP (write-off)
INVOICED Manufacturer statement received and matched to the accrual grain Invoice line loaded for contract/GPI/period invoiced_amount (Decimal) PARTIALLY_COLLECTED, DISPUTED, TRUED_UP
PARTIALLY_COLLECTED Cash remitted, but less than invoiced Remittance posts below invoiced_amount collected_amount (Decimal) DISPUTED, TRUED_UP
DISPUTED Variance beyond tolerance, escalated to manufacturer |variance| > tolerance band, or short-pay frozen at last known basis PARTIALLY_COLLECTED, TRUED_UP
TRUED_UP Closing adjustment posted; receivable retired for the period Period close reconciles accrual to cash trued_up_amount (Decimal) terminal

Two structural guards wrap the machine. First, a transition is only legal if the source state permits it; an out-of-order transition (for example a remittance arriving before any invoice is loaded) is quarantined for investigation rather than force-applied, because it usually signals a grain or keying defect. Second, TRUED_UP is terminal for that period only — a receivable that is trued up in one period can be reopened by a restated invoice in a later period, which posts a fresh delta rather than mutating the closed record. The mechanics of that closing adjustment are specified in Automating Rebate True-Up Adjustments.

Rebate receivable reconciliation state machine A receivable begins in the ACCRUED state when an estimate is booked from adjudicated utilization. It advances to INVOICED when a manufacturer statement is matched at the contract, GPI and period grain, then to PARTIALLY_COLLECTED when cash lands short of the invoice. From either INVOICED or PARTIALLY_COLLECTED a variance beyond the tolerance band branches to DISPUTED. All of INVOICED, PARTIALLY_COLLECTED and DISPUTED converge on the terminal TRUED_UP state when a closing adjustment reconciles the accrual to collected cash and retires the receivable for the period. ACCRUED estimate booked INVOICED statement matched PARTIALLY_COLLECTED cash < invoiced TRUED_UP retired for period DISPUTED variance > tolerance invoice remit close over tol. resolved → true-up

Figure: The receivable state machine — an accrual advances through invoicing and partial collection, branches to DISPUTED when a variance breaches the tolerance band, and converges on the terminal TRUED_UP state at period close.

Reference Python Implementation

The engine below reconciles a batch of aggregated accruals against loaded invoice and remittance amounts for one GL period. It enforces strict contracts with Pydantic v2, holds every money field as decimal.Decimal, derives a deterministic idempotency key per reconciliation unit so a re-run of the same period close is a no-op, and emits a structured audit event for every state transition. No raw claim payload, 302-C2 Cardholder ID, or 310-CA Patient Name is ever accepted or logged — the accrual record is already tokenized upstream.

python
import json
import logging
import hashlib
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, field_validator, ConfigDict

# PHI GUARDRAIL: only tokenized references and taxonomy/financial identifiers
# reach this logger -- never 302-C2 Cardholder ID, 310-CA Patient Name, or
# raw claim bytes. The accrual record is PHI-tokenized before reconciliation.
logging.basicConfig(format="%(message)s", level=logging.INFO)
logger = logging.getLogger("rebate_reconciliation")

CENT = Decimal("0.01")


class ReconState(str, Enum):
    ACCRUED = "ACCRUED"
    INVOICED = "INVOICED"
    PARTIALLY_COLLECTED = "PARTIALLY_COLLECTED"
    DISPUTED = "DISPUTED"
    TRUED_UP = "TRUED_UP"


# Which target states each source state may legally transition into.
_LEGAL: dict[ReconState, set[ReconState]] = {
    ReconState.ACCRUED: {ReconState.INVOICED, ReconState.TRUED_UP},
    ReconState.INVOICED: {ReconState.PARTIALLY_COLLECTED, ReconState.DISPUTED,
                          ReconState.TRUED_UP},
    ReconState.PARTIALLY_COLLECTED: {ReconState.DISPUTED, ReconState.TRUED_UP},
    ReconState.DISPUTED: {ReconState.PARTIALLY_COLLECTED, ReconState.TRUED_UP},
    ReconState.TRUED_UP: set(),
}


class ReconUnit(BaseModel):
    """One aggregated receivable at the contract / GPI-family / period grain.

    All identifiers are non-PHI. accrued_amount is the sum of claim-level
    estimates; invoiced/collected are populated as manufacturer data arrives.
    """
    model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")

    contract_id: str
    contract_version: str            # versioned snapshot in force at accrual time
    gpi_family: str                  # first 10 GPI digits (therapeutic grouping)
    gl_period: str                   # e.g. "2026-06"
    state: ReconState = ReconState.ACCRUED
    accrued_amount: Decimal          # Decimal, never float
    invoiced_amount: Optional[Decimal] = None
    collected_amount: Optional[Decimal] = None

    @field_validator("accrued_amount", "invoiced_amount", "collected_amount")
    @classmethod
    def _quantize_money(cls, v: Optional[Decimal]) -> Optional[Decimal]:
        if v is None:
            return None
        return v.quantize(CENT, rounding=ROUND_HALF_UP)


class ReconOutcome(BaseModel):
    idempotency_key: str
    contract_id: str
    gpi_family: str
    gl_period: str
    prior_state: ReconState
    new_state: ReconState
    variance: Decimal                # accrued - collected (or - invoiced)
    variance_pct: Decimal
    classification: str              # matched | over_accrued | under_accrued | disputed
    reconciled_at: str


def _idem_key(unit: ReconUnit) -> str:
    """Deterministic key over the reconciliation grain + basis versions.

    Re-running the same period close over unchanged inputs yields the same
    key, so downstream posting can dedupe and stay exactly-once.
    """
    basis = "|".join([
        unit.contract_id, unit.contract_version, unit.gpi_family,
        unit.gl_period,
        str(unit.invoiced_amount), str(unit.collected_amount),
    ])
    return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]


class ReconciliationEngine:
    """Idempotent period-close reconciliation with tolerance-banded states."""

    def __init__(self, tolerance_pct: Decimal = Decimal("0.02")) -> None:
        # 2% default auto-clear band; below this a variance is treated as noise.
        self._tolerance = tolerance_pct

    def _transition(self, src: ReconState, dst: ReconState) -> ReconState:
        if dst not in _LEGAL[src]:
            # Out-of-order transitions signal a grain/keying defect: quarantine
            # by refusing to advance and flagging for investigation.
            raise ValueError(f"illegal transition {src} -> {dst}")
        return dst

    def reconcile(self, unit: ReconUnit) -> ReconOutcome:
        now = datetime.now(timezone.utc).isoformat()
        prior = unit.state

        # Basis for variance: cash if collected, else invoiced, else accrued.
        settled = unit.collected_amount
        if settled is None:
            settled = unit.invoiced_amount
        if settled is None:
            settled = unit.accrued_amount

        variance = (unit.accrued_amount - settled).quantize(CENT, ROUND_HALF_UP)
        base = unit.accrued_amount if unit.accrued_amount != 0 else Decimal("1")
        variance_pct = (abs(variance) / base).quantize(
            Decimal("0.0001"), rounding=ROUND_HALF_UP)

        # Decide the target state from the available facts.
        if unit.collected_amount is not None:
            if unit.invoiced_amount is not None and \
                    unit.collected_amount < unit.invoiced_amount:
                target = ReconState.PARTIALLY_COLLECTED
            else:
                target = ReconState.TRUED_UP
        elif unit.invoiced_amount is not None:
            target = ReconState.INVOICED
        else:
            target = ReconState.ACCRUED

        # Tolerance gate: a material variance escalates to DISPUTED rather than
        # auto-clearing, unless we are already booking the closing true-up.
        if variance_pct > self._tolerance and target != ReconState.TRUED_UP \
                and target in _LEGAL[prior] | {ReconState.DISPUTED}:
            target = ReconState.DISPUTED

        new_state = self._transition(prior, target) if target != prior else prior

        if variance == 0:
            classification = "matched"
        elif new_state == ReconState.DISPUTED:
            classification = "disputed"
        elif variance > 0:
            classification = "over_accrued"   # booked more than collected
        else:
            classification = "under_accrued"  # booked less than collected

        outcome = ReconOutcome(
            idempotency_key=_idem_key(unit),
            contract_id=unit.contract_id,
            gpi_family=unit.gpi_family,
            gl_period=unit.gl_period,
            prior_state=prior,
            new_state=new_state,
            variance=variance,
            variance_pct=variance_pct,
            classification=classification,
            reconciled_at=now,
        )
        self._audit(outcome)
        return outcome

    def _audit(self, outcome: ReconOutcome) -> None:
        # Audit event carries only non-PHI financial/taxonomy identifiers.
        logger.info(json.dumps({
            "event": "rebate_reconciliation",
            "idempotency_key": outcome.idempotency_key,
            "contract_id": outcome.contract_id,
            "gpi_family": outcome.gpi_family,
            "gl_period": outcome.gl_period,
            "prior_state": outcome.prior_state.value,
            "new_state": outcome.new_state.value,
            "variance": str(outcome.variance),
            "variance_pct": str(outcome.variance_pct),
            "classification": outcome.classification,
            "timestamp": outcome.reconciled_at,
        }))

The engine is a pure function of (ReconUnit, tolerance): it holds no mutable state between calls, so it can be fanned out across workers and replayed against a historical period without side effects. The idempotency_key folds the contract version and the invoiced/collected amounts into a stable hash, which means a period close that re-runs over identical inputs produces identical keys — the posting layer downstream can dedupe on that key and hold exactly-once semantics even when a batch is retried after a mid-run failure, the same concurrency posture used by Asynchronous Batch Adjudication Workflows.

Aging, Variance Analysis, and Audit Trails

Reconciliation produces three durable artifacts beyond the state transition itself: an aging schedule, a variance analysis, and an audit trail — and each has to be reproducible from stored snapshots, not recomputed against live data.

Aging answers “how long has this receivable been outstanding?” A unit that reaches INVOICED but never fully collects accrues age from the invoice date carried on the record. The engine buckets outstanding balances into 0–30, 31–60, 61–90, and 90+ day bands so collections can prioritize the oldest exposure and finance can reserve against receivables unlikely to convert. Because the bucket boundary is computed from the invoice date on the unit rather than from wall-clock time at report generation, the same aging report re-run a week later reproduces identical buckets for a given as-of date — a property auditors test directly.

Variance analysis decomposes the gap between accrued and settled into causes rather than reporting a single lump number. A material variance is attributed to one of a small set of buckets — timing (invoice landed in a different period than the accrual), rate (the manufacturer applied a different contracted rate than the accrual assumed), utilization (claims were restated after accrual), or dispute (a line the manufacturer refuses to pay). Attribution turns an opaque over_accrued figure into an actionable finding: a timing variance self-resolves next period and needs no dispute, while a rate variance points straight at a contract-modeling defect worth escalating to Manufacturer Rebate Contract Modeling. The classification field on every ReconOutcome is the seed of that decomposition.

Audit trails are append-only and PHI-free. Every transition, classification, and true-up serializes a structured event carrying the reconciliation grain, the contract and spread-basis versions, the amounts, and a timestamp — never a claim payload. Because each event references the exact snapshot versions in force, an examiner can replay a period close months later and arrive at the identical set of variances and buckets, which is the whole point of stamping versions on the accrual in the first place.

Engineering Constraints & Known Failure Modes

Reconciliation fails in a handful of recurring, diagnosable ways. Each gets a deterministic rule, not an ad-hoc patch:

  • Timing mismatches across period boundaries. A claim adjudicated at the very end of a period accrues into that period, but the manufacturer invoices it in the next. Naive matching then reports the entire accrual as an unmatched variance. The fix is to reconcile on the accrual period recorded on the unit, never the invoice-receipt date, and to carry an explicit gl_period on every record so a cross-period invoice is matched back to the period that booked the accrual.
  • Partial collections. Manufacturers routinely remit less than they invoice — administrative fees, disputed lines, negotiated adjustments. Treating a short-pay as a full settlement writes off real receivable; treating it as a total loss over-reserves. The PARTIALLY_COLLECTED state exists precisely so the residual stays open and aged rather than being silently trued up to zero.
  • Restated utilization. When upstream claims are reversed or re-adjudicated after an accrual is booked, the accrued estimate no longer reflects live utilization. The engine must reconcile against the snapshot of utilization captured at accrual time, and treat a restatement as a new delta in the period it occurs — never as a silent mutation of a closed accrual.
  • Duplicate accruals. A retried adjudication or a double-loaded claim batch can book the same receivable twice, inflating the accrued side of every reconciliation. Deduplicate accrual inputs on the tokenized 402-D2 reference plus GPI plus dispensing date before aggregating to the reconciliation grain; a duplicate that survives into the sum looks exactly like an under-collection and pollutes the variance report.
  • Grain mismatch. If accruals are keyed at 11-digit NDC but the invoice is structured at GPI family, matching produces variances that are pure bucketing artifacts. Aggregate both sides to the same declared grain — contract by GPI family by period — before any subtraction, a discipline detailed in Reconciling Accrued vs Invoiced Rebates.
  • PHI leakage in variance reports. The tempting exception handler that dumps the offending records for debugging is the classic HIPAA exposure. Every reconciliation artifact — audit event, variance report, dispute packet — carries only tokenized references and financial identifiers. Raw claim bytes, 302-C2, and 310-CA never persist to a reconciliation log or export.

Performance & Correctness Tuning

  • Idempotent period close. The close is designed to be re-runnable. Because each ReconUnit yields a deterministic idempotency_key, running the close twice over the same inputs posts nothing new the second time. This is what lets an operator safely re-trigger a close that failed halfway without double-counting.
  • Decimal precision end to end. Every money field is decimal.Decimal, quantized to cents with ROUND_HALF_UP at the boundary. A single float receivable introduces representation error that compounds across thousands of aggregated claim-level accruals and shows up as a spurious sub-cent variance on every line. Consult the Python decimal documentation for the rounding-context semantics the engine relies on.
  • Versioned snapshots for replay. The contract version and spread basis are stamped on every accrual, so a reconciliation can be replayed exactly as it ran during a payer examination even after published WAC/AMP values or contract terms are amended. The Pydantic v2 model configuration enforces the strict, extra-forbidding contracts that keep those snapshots clean.
  • Aging as a first-class output. A receivable that sits in INVOICED or PARTIALLY_COLLECTED past a threshold is aged into buckets (0–30, 31–60, 61–90, 90+ days) so collections can prioritize and finance can reserve. Aging is computed from the invoice date carried on the unit, not from wall-clock time at report generation, so the report is deterministic and reproducible.
  • Batch fan-out with a shared engine. Period-close workloads route thousands of ReconUnit records through the same stateless engine via asyncio.gather or a worker pool, matching the concurrency model of the ingestion path while keeping each reconciliation a pure, independently retryable unit.

In This Section

  • Reconciling Accrued vs Invoiced Rebates — matching accrued estimates against manufacturer-invoiced amounts at the contract/GPI/period grain, classifying variances, and choosing a tolerance-band auto-clear versus flag strategy.
  • Automating Rebate True-Up Adjustments — computing and posting the closing adjustment when final invoiced or collected rebates differ from the accrual, without double-counting across periods.

← Back to Rebate Calculation & Accrual Tracking