Skip to content

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.

Routing an unauthorized retailer deduction into the recovery workflow A retailer deduction carrying a reason code and short-pay amount enters a classifier that matches it against open accruals within a decimal tolerance band. A deduction that matches a valid accrual and carries a recognized reason code exits to the right as an authorized net-off requiring no recovery. A deduction with no matching accrual or an invalid reason code is declared unauthorized and descends four recovery stages: build a recovery packet bundling the agreement version, the accrual trace, and the source invoice; assign a priority and aging bucket from the deduction age and exposure; enqueue the packet to the recovery queue with an idempotency key; and post a decimal-exact offsetting receivable entry that reverses the short-pay and anchors the chargeback. Every stage carries a stable recovery_id so the packet is reconstructable point-in-time. Retailer deduction reason_code · short-pay amount · invoice ref Classify against open accruals Decimal tolerance band · reason_code lookup match Authorized net-off · no recovery no match / invalid reason_code → unauthorized 1 Build recovery packet agreement version · accrual trace · invoice 2 Assign priority & aging exposure × days-to-window-close 3 Enqueue to recovery queue idempotency_key · dedup guard 4 Post offsetting entry Decimal receivable reverses short-pay anchors the chargeback · stable recovery_id

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, a reason_code, an amount expressed as a decimal string, a deducted_on date, and the invoice_number it was netted against. A deduction with a float amount 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 remaining open_balance as a decimal.Decimal.
  • A governed reason-code table. Every retailer reason_code maps to one of authorized, disputable, or invalid, plus a dispute_window_days. The table is versioned alongside the agreement registry from agreement schema design; an unrecognized code defaults to invalid rather than silently passing.
  • Python packages: pydantic>=2.6 and the standard-library decimal module. Every monetary field uses decimal.Decimal with an explicit quantize; never float.
  • Access roles. The deduction_recovery service role holds read access to the accrual index and the reason-code table, and write access to the recovery_queue and the recovery_ledger. Releasing or writing off a queued recovery requires a separate trade_finance_approver role — 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.

python
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.

python
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.

python
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.

python
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.

python
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

  1. Float drift in the netting comparison. Comparing ded.amount - accrual_open with a float tolerance 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 with decimal.Decimal, keep TOLERANCE a Decimal, and compare with abs(...) <= TOLERANCE (Step 1). Never mix a float threshold into decimal money.
  2. 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 authorized reason class and a within-tolerance accrual match before netting, and route genuine disputable codes 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.
  3. 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 empty accrual_trace for no-match cases, and treat a packet missing any of the three evidence pieces as blocked, not queued.
  4. 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_id from 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.
  5. Expired dispute window. Queuing a recovery after the retailer’s dispute_window_days has lapsed wastes effort on an unrecoverable amount and hides a real write-off. Fix: compute days_to_window_close against deducted_on (Step 3) and return EXPIRED at 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.

Up one level: Deduction & Short-Pay Routing