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.
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.
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 outcomesGrouping 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.
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 retryThe 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-D7NDC 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
floatamount reintroduces representation error that shows up as a spurious sub-cent variance on every line. Parse amounts throughDecimal(str(...))at ingestion and quantize withROUND_HALF_UP, per the Python decimal documentation. - PHI in variance exports. A dispute packet sent to a manufacturer must never carry
302-C2Cardholder ID,310-CAPatient Name, or raw claim bytes. Export only the tokenized402-D2reference, 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.
Related
- Rebate Accrual Reconciliation — the reconciliation workflow this matching step runs inside.
- Automating Rebate True-Up Adjustments — the sibling step that posts a closing adjustment for the variances this page classifies.
- Manufacturer Rebate Contract Modeling — the contract terms that set the accrued estimate each invoice is matched against.
- Rebate Invoice Generation — the invoice side of the match, whose line structure dictates the grain.
- NDC-to-GPI Crosswalk Automation — the resolution that makes GPI-family grouping trustworthy on both sides of the match.
← Back to Rebate Accrual Reconciliation