Routing Unauthorized Deductions for Recovery
A retailer short-pays an invoice, cites a reason code, and nets the difference against the remittance — but no signed promotion, accrual, or agreed allowance backs the amount. Left unhandled, that unauthorized deduction quietly becomes a write-off: the cash never arrives, the accrual never clears, and by the time anyone notices, the dispute window has closed. This page documents the exact procedure for catching that deduction and routing it into recovery: classify it against open accruals with a decimal tolerance, assemble an evidence-backed recovery packet, assign a priority and aging bucket, enqueue it to the recovery queue, and post the offsetting entry that keeps the general ledger honest. It is the implementation-level companion to the deduction and short-pay routing topic area inside Settlement, Deduction & Financial Close.
Prerequisites
Before wiring deduction recovery into the settlement pipeline, confirm the following are in place:
- A normalized deduction data contract. Each deduction arrives from remittance-advice parsing (see handling EDI 844 rebate claims and the broader data ingestion normalization pipelines) as a single record with a
deduction_id, areason_code, anamountexpressed as a decimal string, adeducted_ondate, and theinvoice_numberit was netted against. A deduction with afloatamount or an un-normalized reason code is a data defect, not an input. - An open-accrual index keyed for lookup. Recovery classification resolves against the accruals produced by payout structure modeling and posted through the accrual-to-ledger flow. The index must expose open accruals keyed on
(vendor_id, promotion_id, period)with the remainingopen_balanceas adecimal.Decimal. - A governed reason-code table. Every retailer
reason_codemaps to one ofauthorized,disputable, orinvalid, plus adispute_window_days. The table is versioned alongside the agreement registry from agreement schema design; an unrecognized code defaults toinvalidrather than silently passing. - Python packages:
pydantic>=2.6and the standard-librarydecimalmodule. Every monetary field usesdecimal.Decimalwith an explicitquantize; neverfloat. - Access roles. The
deduction_recoveryservice role holds read access to the accrual index and the reason-code table, and write access to therecovery_queueand therecovery_ledger. Releasing or writing off a queued recovery requires a separatetrade_finance_approverrole — the pipeline can open a recovery but cannot close one against the vendor’s interest.
Step-by-Step Implementation
Step 1 — Classify the deduction against open accruals
Model the incoming deduction as a typed record, then decide whether it is authorized. A deduction is authorized only when its reason_code resolves to authorized and it matches an open accrual within a decimal tolerance band; anything else is unauthorized and enters recovery. The tolerance absorbs legitimate penny-rounding between the retailer’s math and yours without letting a materially wrong amount pass.
from decimal import Decimal
from datetime import date
from enum import Enum
from pydantic import BaseModel, field_validator
class ReasonClass(str, Enum):
authorized = "authorized"
disputable = "disputable"
invalid = "invalid"
class Deduction(BaseModel):
deduction_id: str
vendor_id: str
reason_code: str
amount: Decimal
deducted_on: date
invoice_number: str
@field_validator("amount", mode="before")
@classmethod
def _reject_float(cls, v):
if isinstance(v, float):
raise ValueError("amount must be a decimal string, not a float")
return Decimal(v)
TOLERANCE = Decimal("0.05") # per-line penny-rounding band
def classify(ded: Deduction, accrual_open: Decimal | None,
reason_class: ReasonClass) -> str:
if reason_class is ReasonClass.invalid or accrual_open is None:
return "unauthorized"
if abs(ded.amount - accrual_open) <= TOLERANCE:
return "authorized"
return "unauthorized"
Validation check: feed a deduction whose amount differs from the matched accrual by Decimal("0.03") and assert it classifies authorized; widen the gap to Decimal("0.06") and assert unauthorized. Pass accrual_open=None and assert unauthorized regardless of reason class. The tolerance is compared with Decimal, never float, so the boundary is exact.
Step 2 — Build the recovery packet
An unauthorized deduction is only recoverable if you can prove it. Assemble a packet that bundles the three pieces of evidence a retailer’s deduction desk will demand: the agreement version in force on deducted_on, the accrual trace showing there was no open liability to net against, and the source invoice the deduction hit. The packet is immutable once built and carries a stable recovery_id so it is reconstructable point-in-time.
import hashlib
import json
class RecoveryPacket(BaseModel):
recovery_id: str
deduction_id: str
vendor_id: str
amount: Decimal
agreement_version: str # version in force on deducted_on
accrual_trace: list[dict] # matched/near-miss accruals with balances
invoice_number: str
built_at: str # ISO 8601 UTC
def build_packet(ded: Deduction, agreement_version: str,
accrual_trace: list[dict], built_at: str) -> RecoveryPacket:
seed = f"{ded.deduction_id}|{ded.invoice_number}|{agreement_version}"
recovery_id = "REC-" + hashlib.sha256(seed.encode()).hexdigest()[:16]
return RecoveryPacket(
recovery_id=recovery_id,
deduction_id=ded.deduction_id,
vendor_id=ded.vendor_id,
amount=ded.amount,
agreement_version=agreement_version,
accrual_trace=accrual_trace,
invoice_number=ded.invoice_number,
built_at=built_at,
)
Validation check: assert the packet rejects construction when accrual_trace is empty for a no-match classification — an unauthorized recovery with no evidence of the absent accrual cannot be defended. Re-run build_packet with identical inputs and assert the recovery_id is byte-stable; a drifting id means the seed pulled in a non-deterministic field.
Step 3 — Assign priority and aging
Not every recovery deserves the same urgency. Score each packet on exposure (the recoverable amount) and time pressure (days remaining before the retailer’s dispute_window_days closes), then bucket it. A high-value deduction three days from its window deadline must outrank a small one with a month of runway — the aging bucket, not the arrival order, drives the queue.
def days_to_window_close(ded: Deduction, window_days: int, today: date) -> int:
deadline_ordinal = ded.deducted_on.toordinal() + window_days
return deadline_ordinal - today.toordinal()
def assign_priority(amount: Decimal, days_left: int) -> tuple[str, int]:
if days_left <= 0:
return ("EXPIRED", 0) # window closed — escalate, do not auto-queue
if amount >= Decimal("10000") or days_left <= 5:
bucket = "P1"
elif amount >= Decimal("1000") or days_left <= 15:
bucket = "P2"
else:
bucket = "P3"
# rank: larger exposure and less runway sort first
rank = int(amount) - days_left * 10
return (bucket, rank)
Validation check: assert a Decimal("12000") deduction with 20 days left buckets P1 on exposure alone, and a Decimal("200") deduction with 4 days left buckets P1 on time pressure alone. Assert days_left <= 0 returns EXPIRED so the packet is escalated to manual review rather than silently enqueued past a closed window.
Step 4 — Enqueue to the recovery queue
Push the prioritized packet onto the recovery queue under an idempotency key so a re-run of the settlement batch cannot open the same chargeback twice. The key is derived from the immutable recovery_id; the queue rejects a second insert for a key it has already seen, which is the single most important guard against duplicate recovery.
class RecoveryQueue:
def __init__(self):
self._seen: set[str] = set()
self._rows: list[dict] = []
def enqueue(self, packet: RecoveryPacket, bucket: str, rank: int) -> bool:
key = packet.recovery_id # idempotency key
if key in self._seen:
return False # duplicate — already queued
self._seen.add(key)
self._rows.append({
"recovery_id": packet.recovery_id,
"vendor_id": packet.vendor_id,
"amount": str(packet.amount), # decimal as string at rest
"bucket": bucket,
"rank": rank,
"status": "OPEN",
})
return True
Validation check: enqueue the same packet twice and assert the second call returns False and leaves the queue length unchanged. Assert the stored amount is a string, not a float — money crosses the queue boundary as a decimal string to survive serialization without drift.
Step 5 — Post the offsetting entry
The recovery is not real until it is on the books. Post a balanced, decimal-exact entry that reverses the short-pay: debit a deduction-recovery receivable for the recovered amount and credit the disputed-deduction clearing account, anchored to the recovery_id and the original invoice. This reinstates the receivable the retailer erased and gives the chargeback a ledger home that ties out at period close, consistent with the accrual-posting discipline in Settlement, Deduction & Financial Close.
from decimal import ROUND_HALF_UP
def post_offsetting_entry(packet: RecoveryPacket, period: str) -> dict:
amount = packet.amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
entry = {
"recovery_id": packet.recovery_id,
"period": period,
"lines": [
{"account": "deduction_recovery_receivable",
"debit": str(amount), "credit": "0.00"},
{"account": "disputed_deduction_clearing",
"debit": "0.00", "credit": str(amount)},
],
"reference": {"invoice": packet.invoice_number,
"agreement_version": packet.agreement_version},
}
debits = sum(Decimal(l["debit"]) for l in entry["lines"])
credits = sum(Decimal(l["credit"]) for l in entry["lines"])
assert debits == credits, "offsetting entry must balance"
return entry
Validation check: assert the summed debit equals the summed credit to the cent for the posted entry, and that the amount was quantized to two places under ROUND_HALF_UP. Confirm the entry carries the recovery_id and agreement_version so an auditor can walk from the ledger line back to the packet and the contract in force.
Common Failure Modes and Fixes
- Float drift in the netting comparison. Comparing
ded.amount - accrual_openwith afloattolerance lets a deduction that is off by a fraction of a cent classify inconsistently across runs, and rounding error compounds when the same logic nets thousands of lines. Fix: parse every amount withdecimal.Decimal, keepTOLERANCEaDecimal, and compare withabs(...) <= TOLERANCE(Step 1). Never mix a float threshold into decimal money. - Mis-classifying a valid deduction as unauthorized. Treating any short-pay as recoverable generates false chargebacks that damage the vendor relationship and get reversed at review. Fix: require both an
authorizedreason class and a within-tolerance accrual match before netting, and route genuinedisputablecodes to review rather than auto-recovery. When the gap is a missing agreement term rather than an invalid deduction, defer to fallback routing logic instead of opening a chargeback. - Missing evidence in the packet. A recovery submitted without the agreement version, accrual trace, or source invoice is unwinnable — the retailer’s deduction desk rejects it and the window expires. Fix: make
build_packet(Step 2) refuse an emptyaccrual_tracefor no-match cases, and treat a packet missing any of the three evidence pieces as blocked, not queued. - Duplicate recovery. Re-running the settlement batch, or a deduction appearing on two remittances, can open the same chargeback twice and double-count the receivable. Fix: derive a stable
recovery_idfrom immutable fields and enqueue under it as an idempotency key (Steps 2 and 4); the queue rejects a repeat key. This is the deduction-side analogue of detecting duplicate deduction claims. - Expired dispute window. Queuing a recovery after the retailer’s
dispute_window_dayshas lapsed wastes effort on an unrecoverable amount and hides a real write-off. Fix: computedays_to_window_closeagainstdeducted_on(Step 3) and returnEXPIREDat or before zero, escalating to manual review and a write-off decision rather than silently enqueuing a dead recovery.
Operational Checklist
Frequently Asked Questions
When is a short-pay authorized versus recoverable? A deduction is authorized only when its reason code resolves to authorized in the governed table and its amount matches an open accrual within the decimal tolerance band. If either condition fails — an unrecognized or invalid reason code, or no accrual to net against — the deduction is unauthorized and enters the recovery workflow. Disputable codes that are neither clearly valid nor clearly invalid route to human review rather than an automatic chargeback.
Why derive the recovery_id from immutable fields instead of a sequence? A monotonic sequence changes every run, so a re-executed settlement batch would open a fresh chargeback for a deduction already in the queue. Hashing the deduction id, invoice number, and agreement version yields the same recovery_id every time, which lets the queue reject duplicates by key and lets an auditor tie a ledger line back to one specific packet.
What happens when the dispute window has already closed? The priority step computes days remaining from deducted_on plus the retailer’s dispute_window_days. At or below zero it returns EXPIRED, which escalates the packet to manual review and a write-off decision instead of enqueuing it. Recovering against a closed window is not collectible, and hiding it inside the active queue would mask a real loss.
How does the offsetting entry stay in balance across a large batch? Each entry is posted as paired debit and credit lines quantized to two decimal places under ROUND_HALF_UP, and the code asserts the summed debits equal the summed credits before the entry is accepted. Because every amount is a decimal.Decimal rather than a float, the batch ties out to the cent no matter how many recoveries post in the same period.
Related
- Parent cluster: Deduction & Short-Pay Routing — the broader routing architecture this recovery procedure plugs into.
- Matching Vendor Payments to Open Accruals — the accrual-matching layer whose open balances this classifier reads.
- Detecting Duplicate Deduction Claims — the deduplication counterpart that keeps a single deduction from generating two recoveries.
- Payout Structure Modeling — the source of the accruals a deduction is netted against.
Up one level: Deduction & Short-Pay Routing