Reconciling Accrued vs Invoiced Rebates

The decision this page pins down is exact: given a set of claim-level rebate accruals on one side and a manufacturer’s rebate invoice on the other, at what grain do you match them, and how do you classify the difference so the engine knows whether to auto-clear the line or escalate it for a human to chase. Match too finely and every invoice looks like a wall of mismatches because the manufacturer aggregates differently than you accrued; match too coarsely and a real short-pay on one product hides inside a netted total. The right answer is a declared grain — contract by Generic Product Identifier (GPI) family by general-ledger period — with an explicit tolerance band that separates rounding noise from an economic gap worth a dispute. This is the matching step inside Rebate Accrual Reconciliation, and it runs before any true-up is posted.

Match Grain and Tolerance Decision Matrix

There is no single correct grain; the grain is a contract-by-contract choice driven by how the manufacturer structures its statement. The table below is the decision matrix the reconciliation engine consults to pick a matching strategy and a tolerance treatment.

Match grain When it fits Tolerance strategy Auto-clear vs flag Risk if wrong
Contract × GPI family × period Manufacturer invoices by therapeutic family (most common) Percentage band (e.g. 2% of accrued) Auto-clear within band; flag outside Family-level netting hides per-NDC short-pays
Contract × NDC × period Statement itemizes by exact 407-D7 NDC Absolute band (e.g. $5.00/line) Auto-clear within band; flag outside Explodes line count; grain mismatch if you accrued by GPI
Contract × period (rolled up) Manufacturer remits one netted figure Hybrid: percentage on total, absolute floor Flag if either bound breached A single total masks every offsetting error
Contract × GPI × period, sign-aware Reversals/adjustments present in the period Band on net after signed sum Flag any residual after netting reversals Sign error double-counts a reversal

The default is contract by GPI family by period with a percentage tolerance band, because manufacturer statements almost always aggregate at the therapeutic-family level and a fixed percentage adapts to line size. The absolute-band variants matter for high-value specialty products where a 2% band on a large accrual would wave through hundreds of dollars of genuine variance. Whatever the grain, both sides must be aggregated to the same declared grain before subtraction — the single most important rule on this page — because a grain mismatch manufactures variances that are pure bucketing artifacts, not money.

Accrued vs invoiced matching and variance-bucket flow Claim-level accruals and manufacturer invoice lines are each aggregated to the declared contract, GPI-family and period grain. The two aggregates are subtracted to produce a signed variance. If the absolute variance is within the tolerance band the line auto-clears as matched. Outside the band, a positive variance is bucketed as over-accrued and a negative variance as under-accrued, and both route to a flagged dispute queue for review. Claim-level accruals sum of estimates Manufacturer invoice statement lines Aggregate to grain contract × GPI × period |variance| within band? Auto-clear matched Flag → dispute queue over / under-accrued yes no

Figure: Both sides aggregate to the declared grain, subtract to a signed variance, and split on the tolerance band — inside auto-clears as matched, outside buckets as over- or under-accrued and flags for review.

Step-by-Step Implementation

The matcher below groups accruals and invoice lines to the declared grain, computes a signed decimal.Decimal variance, applies the tolerance band, and stays idempotent on re-run. Money is never float; identifiers are the tokenized, non-PHI keys carried on the accrual record.

1. Group both sides to the identical declared grain. Fold accruals and invoice lines into dictionaries keyed by (contract_id, gpi_family, gl_period). Summing to the same key on both sides is what prevents phantom variances.

2. Compute a signed Decimal variance. Subtract invoiced from accrued at each key, quantized to cents. A positive result means you accrued more than the manufacturer billed (over-accrued); negative means under-accrued.

3. Apply the tolerance band. Compare the absolute variance against a percentage of the accrued base. Inside the band, auto-clear as matched; outside, bucket by sign and flag.

4. Emit a deterministic outcome per key. The grain tuple plus the amounts form a stable identity, so re-running the same match posts no new decisions — the engine is safe to retry.

python
from decimal import Decimal, ROUND_HALF_UP
from collections import defaultdict
from typing import Iterable

CENT = Decimal("0.01")


def _group(rows: Iterable[dict], amount_key: str) -> dict[tuple, Decimal]:
    """Aggregate claim/invoice rows to (contract, gpi_family, period) grain."""
    acc: dict[tuple, Decimal] = defaultdict(lambda: Decimal("0.00"))
    for r in rows:
        # r carries tokenized 402-D2 ref upstream; only non-PHI keys used here.
        key = (r["contract_id"], r["gpi_family"], r["gl_period"])
        acc[key] += Decimal(str(r[amount_key]))  # str() guards float drift
    return {k: v.quantize(CENT, ROUND_HALF_UP) for k, v in acc.items()}


def match_accrued_vs_invoiced(
    accruals: Iterable[dict],
    invoice_lines: Iterable[dict],
    tolerance_pct: Decimal = Decimal("0.02"),
) -> list[dict]:
    accrued = _group(accruals, "accrued_amount")
    invoiced = _group(invoice_lines, "invoiced_amount")

    outcomes: list[dict] = []
    for key in sorted(set(accrued) | set(invoiced)):
        a = accrued.get(key, Decimal("0.00"))
        i = invoiced.get(key, Decimal("0.00"))
        variance = (a - i).quantize(CENT, ROUND_HALF_UP)

        base = a if a != 0 else Decimal("1")
        within = abs(variance) <= (base * tolerance_pct).quantize(CENT, ROUND_HALF_UP)

        if variance == 0 or within:
            classification = "matched"
        elif variance > 0:
            classification = "over_accrued"
        else:
            classification = "under_accrued"

        outcomes.append({
            "contract_id": key[0],
            "gpi_family": key[1],
            "gl_period": key[2],
            "accrued": str(a),
            "invoiced": str(i),
            "variance": str(variance),
            "classification": classification,
            "auto_cleared": classification == "matched",
        })
    return outcomes

Grouping over the union of keys is deliberate: a key present in accruals but absent from the invoice (nothing billed) surfaces as a full over-accrual, and a key present only on the invoice (billed but never accrued) surfaces as a full under-accrual, rather than silently disappearing.

Verification and Testing Pattern

The load-bearing properties are: within-band variances auto-clear, out-of-band variances flag with the correct sign, and a re-run produces identical outcomes.

python
import pytest


def _row(c, g, p, key, amt):
    return {"contract_id": c, "gpi_family": g, "gl_period": p, key: amt}


def test_within_band_auto_clears():
    accr = [_row("C1", "27100010", "2026-06", "accrued_amount", "1000.00")]
    inv = [_row("C1", "27100010", "2026-06", "invoiced_amount", "985.00")]
    out = match_accrued_vs_invoiced(accr, inv, Decimal("0.02"))
    assert out[0]["classification"] == "matched"   # 1.5% < 2% band
    assert out[0]["auto_cleared"] is True


def test_short_pay_flags_over_accrued():
    accr = [_row("C1", "27100010", "2026-06", "accrued_amount", "1000.00")]
    inv = [_row("C1", "27100010", "2026-06", "invoiced_amount", "800.00")]
    out = match_accrued_vs_invoiced(accr, inv, Decimal("0.02"))
    assert out[0]["classification"] == "over_accrued"
    assert out[0]["variance"] == "200.00"


def test_rerun_is_idempotent():
    accr = [_row("C1", "27100010", "2026-06", "accrued_amount", "1000.00")]
    inv = [_row("C1", "27100010", "2026-06", "invoiced_amount", "800.00")]
    first = match_accrued_vs_invoiced(accr, inv)
    second = match_accrued_vs_invoiced(accr, inv)
    assert first == second   # pure function of inputs -> safe to retry

The idempotence test is the one that matters operationally: because the matcher is a pure function of its inputs, a period close that re-runs after a mid-batch failure re-derives identical outcomes, so the downstream posting layer can dedupe rather than double-count.

Gotchas and PHI Guardrails

  • Grain mismatch. Accruing at 11-digit 407-D7 NDC while the invoice bills at GPI family produces variances that are pure artifacts of bucketing. Resolve every NDC through the NDC-to-GPI Crosswalk Automation and aggregate both sides to the same declared grain before subtracting.
  • Partial invoices. A manufacturer may bill a period across two statements. Do not treat the first statement as the whole invoice — accumulate invoice lines for the period before matching, or every early match reads as a massive under-accrual that resolves itself once the second statement lands.
  • Sign errors on reversals. Claim reversals and adjustments carry negative amounts. If a reversal is summed as positive, it double-counts against the accrual. Use the sign-aware grain from the matrix and net signed amounts before applying the band.
  • Restated claims. Utilization restated after accrual changes the accrued base. Match against the accrual snapshot captured at booking, and route the restatement as a fresh delta in the current period rather than mutating a closed match.
  • Float contamination. A single float amount reintroduces representation error that shows up as a spurious sub-cent variance on every line. Parse amounts through Decimal(str(...)) at ingestion and quantize with ROUND_HALF_UP, per the Python decimal documentation.
  • PHI in variance exports. A dispute packet sent to a manufacturer must never carry 302-C2 Cardholder ID, 310-CA Patient Name, or raw claim bytes. Export only the tokenized 402-D2 reference, the GPI family, the period, and the amounts.

Once variances are classified, the flagged lines feed the closing adjustment in Automating Rebate True-Up Adjustments; for standard field definitions consult the official NCPDP Standards.

← Back to Rebate Accrual Reconciliation