Deduction & Short-Pay Routing
When a retailer or distributor pays an invoice, they rarely pay it in full. They short-pay — remitting less than the invoiced amount and attaching a deduction that claims a trade-promotion allowance, a shipment shortage, a pricing discrepancy, or a compliance chargeback. Each deduction is a unilateral debit against the vendor’s receivable, and left unmanaged it silently erodes margin: an unauthorized deduction that is never disputed becomes a permanent write-off, while a valid one that is disputed anyway wastes analyst hours and sours the trading relationship. Within the broader settlement, deduction & financial close discipline, this page owns one specific sub-problem — turning an opaque short-pay into a classified, matched, and routed record so that valid deductions net cleanly against the accrual they reference and invalid ones enter a recovery queue with a defensible paper trail.
This is the canonical reference for that routing stage. It specifies the deduction entity the pipeline emits, the reason-code taxonomy that drives conditional matching against open accruals, how deduction amounts are netted with decimal.Decimal precision, the Pydantic v2 model and validators that enforce integrity at the boundary, the drift signals that catch a retailer whose deduction rate is quietly climbing, and the fallback logic that sends each deduction down exactly one of three lanes — auto-net, recovery, or manual review — under an append-only audit log. The audience is concrete: trade finance analysts who adjudicate the dispute, vendor managers who answer for the recovery number, and the Python developers who own the routing engine.
Positioning Within the Reconciliation Architecture
Deduction routing sits at the boundary between the claim side of the ledger and the cash side. Upstream, the pipeline has already rated promotions and posted accruals — a liability the vendor expects to settle against. When a retailer short-pays, they are unilaterally asserting that one of those liabilities is due now and taking it, whether or not the vendor agrees. The routing engine’s job is to reconcile that assertion against the vendor’s own record of what was earned. It consumes two inputs: the deduction backup extracted from the remittance advice, and the set of open accruals produced by payout structure modeling. It emits a single classified disposition per deduction plus an immutable audit entry.
Two design commitments keep this defensible. First, the engine never silently absorbs a deduction — every short-pay is explicitly classified as valid, invalid, or ambiguous, and even an auto-netted deduction leaves a record of the accrual it consumed, so no dollar leaves the receivable without a trace. Second, classification is separated from adjudication: the engine decides which lane a deduction belongs in using deterministic rules, but the actual write-off or recovery decision on an ambiguous item is made by a human whose action is logged. The deduction routing engine leans directly on the claim validation rule engine for the predicates that decide whether a referenced promotion was genuinely earned, and it hands unauthorized items to the recovery workflow detailed in routing unauthorized deductions for recovery. A closely related failure mode — the same claimed amount arriving twice under two reason codes — is owned by the duplicate-claim work under detecting duplicate deduction claims, and this engine calls that check before it nets anything so a replayed remittance cannot double-consume an accrual.
Entity Topology and Schema Specification
A deduction is only routable if the record representing it is stable, typed, and self-describing. Each short-pay line on a remittance resolves to exactly one Deduction entity, keyed independently of the invoice so that a single invoice short-paid for three different reasons yields three deductions, each matchable to its own accrual. The claimed amount is the retailer’s assertion; it is never mutated after receipt — netting and recovery produce derived balances that reference it rather than overwriting it. The table below specifies the minimum schema the router emits; treat it as a versioned interface where adding a nullable field is backward-compatible while renaming or retyping one is a breaking change.
| Field | Type | Constraint |
|---|---|---|
deduction_id |
str (UUID) |
primary, assigned at ingestion |
retailer_id |
str |
required, trading-partner origin |
invoice_ref |
str |
required, source invoice the short-pay debits |
remittance_ref |
str |
required, remittance advice the deduction rode in on |
reason_code |
enum |
normalized taxonomy code (see below) |
raw_reason |
str |
original retailer-supplied reason text, pre-normalization |
claimed_amount |
Decimal |
> 0, scale 2, retailer’s asserted debit |
currency |
str (ISO 4217) |
as-reported |
matched_accrual_id |
str | None |
FK to open accrual, null until matched |
matched_amount |
Decimal |
scale 2, portion netted against the accrual |
status |
enum |
new, matched, netted, in_review, in_recovery, settled, written_off |
disposition |
enum |
auto_net, recovery, manual_review, pending |
aging_days |
int |
>= 0, days since remittance date |
deduction_date |
str (ISO-8601) |
date the retailer took the debit |
The deduction_id is assigned once at ingestion and never reused, so the audit log can reference a deduction across every lane transition without ambiguity. The split between claimed_amount and matched_amount is deliberate: a retailer may claim more than the accrual can support, and the difference between the two is precisely the amount that flows to recovery. Holding both as Decimal from ingestion — never float — means the netting arithmetic is reproducible to the cent regardless of platform. aging_days is not stored statically; it is recomputed on read against deduction_date, because an unresolved deduction’s age is the primary driver of both drift alerts and the deadline past which a retailer’s chargeback window closes and recovery becomes materially harder.
Conditional Logic and Rule Integration
Routing turns on two questions asked in sequence: is the reason code one the vendor recognizes as potentially valid, and does the deduction match an open accrual within tolerance. Neither question is a free-text guess; both are deterministic predicates over the normalized taxonomy. Retailers each use their own deduction vocabulary, so the first job is normalization — mapping a retailer’s raw reason string onto a canonical reason_code. The taxonomy below groups those canonical codes by how they must be handled.
reason_code |
Category | Requires accrual match | Default lane if unmatched |
|---|---|---|---|
TRADE_ALLOWANCE |
Promotional deduction | yes | manual review |
OFF_INVOICE_DISCOUNT |
Promotional deduction | yes | manual review |
SCAN_REBATE |
Promotional deduction | yes | manual review |
SHORTAGE |
Logistics claim | no (proof-of-delivery) | recovery |
DAMAGE |
Logistics claim | no (inspection record) | recovery |
PRICING_ERROR |
Pricing dispute | no (contract price check) | recovery |
COMPLIANCE_CHARGEBACK |
Penalty | no (routing-guide rule) | recovery |
UNAUTHORIZED |
Unrecognized | n/a | recovery |
AMBIGUOUS |
Unresolvable at ingest | n/a | manual review |
The distinction that drives everything is whether a code requires an accrual match. A TRADE_ALLOWANCE deduction is a claim against a promotion the vendor may have accrued for; if it references a real, open accrual within the amount and period tolerance, it is a valid short-pay and can auto-net. If it references no accrual — because the promotion was never agreed, already settled, or the retailer over-claimed — it is unauthorized and routes to recovery. Codes such as SHORTAGE or COMPLIANCE_CHARGEBACK are not promotional at all; they can never match an accrual, so they route to recovery for evidence-based adjudication unless a separately validated shortage record exists. The matching predicate itself defers to the claim validation rule engine for the underlying eligibility test, and consults the eligibility rule framework to confirm the promotion window and product scope actually cover the deducted line before any accrual balance is consumed.
{
"classifier": {
"match_tolerance": {"amount_pct": "0.02", "period_days": 30},
"requires_accrual_match": ["TRADE_ALLOWANCE", "OFF_INVOICE_DISCOUNT", "SCAN_REBATE"],
"recovery_default": ["SHORTAGE", "DAMAGE", "PRICING_ERROR", "COMPLIANCE_CHARGEBACK", "UNAUTHORIZED"],
"route": {
"valid_and_matched": "auto_net",
"invalid_or_unmatched": "recovery",
"partial_or_over_tolerance": "manual_review"
}
}
}
Financial Settlement Layer
Netting is where precision is won or lost. When a valid promotional deduction matches an open accrual, the router reduces the accrual balance by the matched portion of the claimed amount and marks the deduction settled. The arithmetic must be exact: a retailer that short-pays 1,204.37 against an accrual of 1,180.00 has over-claimed by 24.37, and that 24.37 — not a penny more or less — is what flows to recovery. Routing any part of this through float would inject platform-dependent drift that is invisible on one deduction but material across a period’s worth of them, and because the netted figures feed the general ledger and must reconcile to the cent under IFRS 15 / ASC 606, float is forbidden anywhere in the settlement path.
The netting function takes the claimed amount and the open accrual balance, computes how much can be validly absorbed, and returns the netted amount plus any residual that must be recovered. It never lets an accrual go negative, and it quantizes every result to two places with explicit rounding.
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
CENTS = Decimal("0.01")
@dataclass(frozen=True)
class NetResult:
netted: Decimal # absorbed against the accrual
residual: Decimal # over-claim routed to recovery
accrual_after: Decimal
def net_deduction(claimed: Decimal, accrual_balance: Decimal) -> NetResult:
"""Net a validated deduction against an open accrual; residual goes to recovery."""
if claimed <= Decimal("0"):
raise ValueError("claimed_amount must be positive")
absorbable = min(claimed, accrual_balance)
netted = absorbable.quantize(CENTS, rounding=ROUND_HALF_UP)
residual = (claimed - netted).quantize(CENTS, rounding=ROUND_HALF_UP)
accrual_after = (accrual_balance - netted).quantize(CENTS, rounding=ROUND_HALF_UP)
return NetResult(netted=netted, residual=residual, accrual_after=accrual_after)
A deduction with a non-zero residual is not fully settled: the netted portion closes against the accrual, and a child recovery record for the residual is opened automatically. This split is what lets a single retailer over-claim be simultaneously honoured in part and recovered in part, without the two dispositions contaminating each other’s audit trail. The netted amounts are the numbers that later post to the ledger through the accrual posting & gl integration layer, so their precision is not a parsing nicety — it is the integrity of the close.
ETL Implementation Patterns
Enforcement begins at the model boundary. A deduction that arrives with a float claimed amount, a negative value, a status inconsistent with its matched state, or a reason code outside the taxonomy must fail fast at ingestion rather than surface weeks later as a mis-routed write-off. Model the record with Pydantic v2 so those invariants are enforced structurally. The validators below reject the three most common silent corruptions — a float amount, a matched status with no matched_accrual_id, and a claimed amount that is zero or negative.
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator
class Status(str, Enum):
new = "new"; matched = "matched"; netted = "netted"
in_review = "in_review"; in_recovery = "in_recovery"
settled = "settled"; written_off = "written_off"
class Deduction(BaseModel):
deduction_id: str
retailer_id: str
invoice_ref: str
reason_code: str
claimed_amount: Decimal
matched_accrual_id: str | None = None
matched_amount: Decimal = Decimal("0.00")
status: Status = Status.new
aging_days: int = 0
@field_validator("claimed_amount", "matched_amount", mode="before")
@classmethod
def _no_float_money(cls, v: object) -> Decimal:
if isinstance(v, float):
raise ValueError("monetary values must arrive as str or Decimal, never float")
return Decimal(str(v))
@field_validator("claimed_amount")
@classmethod
def _positive_claim(cls, v: Decimal) -> Decimal:
if v <= Decimal("0"):
raise ValueError("claimed_amount must be positive")
return v
@model_validator(mode="after")
def _match_consistency(self) -> "Deduction":
if self.status in (Status.matched, Status.netted) and not self.matched_accrual_id:
raise ValueError("matched/netted status requires a matched_accrual_id")
if self.matched_amount > self.claimed_amount:
raise ValueError("matched_amount cannot exceed claimed_amount")
return self
Persistence uses idempotent upserts keyed on deduction_id, so a retailer re-transmitting the same remittance overwrites cleanly instead of opening a duplicate recovery. Before any netting runs, the engine calls the duplicate-claim check so a short-pay that has already consumed an accrual under a prior remittance cannot consume it again. Schema evolution follows semantic versioning: a new reason code or nullable field ships behind a dual-read window so historical deductions remain reproducible under the taxonomy version that first classified them. The upstream remittance extraction — turning EDI 812 debit adjustments and 820 remittance advice into typed Deduction records — is handled by the data ingestion normalization pipelines layer before a single deduction reaches this router.
Drift Detection and Validation
A routing engine is only trustworthy if the population of deductions it processes keeps matching the shape the trading relationship should produce. Drift detection runs statistical checks per retailer and per reason code, comparing the current period against a rolling baseline, and flags anomalies for review rather than letting them accrue silently. The most valuable signal is a retailer whose deduction rate — deductions as a percentage of invoiced value — climbs quietly quarter over quarter, because that pattern is how systematic over-deduction hides in plain sight.
| Drift signal | Detection rule | Action |
|---|---|---|
| Deduction-rate climb | retailer’s deduction % of invoiced value up > tolerance vs rolling baseline | flag retailer, escalate to vendor manager |
| Reason-code skew | share of UNAUTHORIZED / unmatched codes exceeds threshold |
hold batch, sample for review |
| Aging breach | aging_days past chargeback-window deadline |
prioritize recovery, alert on-call |
| Match-rate drop | auto-net rate falls below baseline for a retailer | investigate accrual coverage gap |
| Duplicate spike | repeated claimed_amount + invoice_ref pairs |
route to duplicate-claim detection |
Catching these before the deductions settle is what preserves margin: a deduction-rate climb caught in-period is a renegotiation conversation, while the same pattern discovered a year later is an unrecoverable write-off. When a signal exceeds its tolerance the engine raises an exception ticket with the offending deductions attached and holds the affected batch from auto-netting until an analyst clears it, so a drifting retailer cannot quietly train the auto-net lane to accept ever-larger claims.
Fallback and Dispute Routing
Ambiguity is inevitable in a multi-retailer ecosystem, so the router degrades deterministically rather than guessing. Every deduction lands in exactly one of three lanes, and the routing rule is total — there is no path by which a deduction is neither netted, recovered, nor reviewed. Valid, matched deductions — a recognized promotional reason code matched to an open accrual within amount and period tolerance — flow to the auto-net lane, where the claimed amount nets against the accrual and the deduction is marked settled. Invalid deductions — an unauthorized reason code, a claim referencing no accrual, or an over-claim residual — flow to the recovery and chargeback queue, where they are packaged with supporting evidence (proof of delivery, contract price, routing-guide rule) for repayment recovery from the retailer. Ambiguous deductions — a partial match, a tolerance breach, or a reason code that could not be normalized with confidence — flow to the manual review lane, where an analyst adjudicates and re-routes the item to either auto-net or recovery.
Each transition writes a structured entry to an append-only audit log: the deduction id, the lane chosen, the rule or analyst that chose it, the accrual consumed if any, and the resulting balances. Nothing is ever silently written off — a write-off is an explicit, logged disposition that an authorized analyst makes in the review lane, never a default the engine falls into. The full mechanics of packaging, evidencing, and pursuing an invalid deduction — including the escalation ladder and the retailer-facing dispute packet — are owned by routing unauthorized deductions for recovery; this engine’s responsibility is to name the correct lane, open the recovery record, and leave a trail that lets trade finance defend every routing decision after the fact. The same discipline of deterministic, logged fallback governs the vendor’s own accrual side through the fallback routing logic layer.
Security and Access Boundaries
Deduction data exposes negotiated allowances, retailer-specific pricing, and the vendor’s dispute posture, so routing is governed as a financial control rather than a utility script. Inbound remittance backup lands in an access-restricted zone; claimed amounts and party identifiers are encrypted at rest, and field-level encryption protects the monetary elements within each Deduction record. Role-based access control (RBAC) tags travel with every entity so authorization is enforced per field: ETL developers can deploy routing-engine versions but cannot mutate a persisted deduction or override a disposition; vendor managers can review the recovery queue and drift alerts but cannot edit a claimed amount; and trade finance analysts can adjudicate ambiguous deductions and authorize write-offs but cannot alter the source remittance bytes. The auto-net lane in particular is bounded by a per-transaction and per-retailer value ceiling — a deduction above the ceiling is forced into manual review regardless of match quality, so no single automated decision can absorb a material amount without a human in the loop.
Immutable, hash-chained audit logs capture every classification, netting, recovery packet, and review decision, and because each deduction is anchored to its ingestion deduction_id and the content_hash of its source remittance, any tampering surfaces as a chain break rather than passing unnoticed — aligning with SOX and internal-control requirements. Treating deduction and short-pay routing as a versioned, access-governed control is what turns unilateral retailer debits into defensible, recoverable financial events: faster settlement of valid claims, disciplined recovery of invalid ones, and a traceable line from every short-pay to the accrual it consumed or the recovery it opened.
Frequently Asked Questions
How does the router decide whether a short-pay is valid or should be recovered? It asks two deterministic questions in sequence. First, is the normalized reason code one the vendor recognizes as potentially valid, such as a trade allowance or scan rebate? Second, does the deduction match an open accrual within the configured amount and period tolerance? A code that is recognized and matched auto-nets; a code that is unauthorized or references no accrual routes to recovery; anything partial or over tolerance goes to manual review. The reason-code taxonomy, not free-text interpretation, drives the branch.
Why must deduction netting use decimal.Decimal instead of float? Because the netted and residual amounts post to the general ledger and must reconcile to the cent. A retailer over-claim of a few cents per deduction is invisible individually but compounds across a period, and float introduces platform-dependent rounding that makes the ledger irreproducible. Claimed amounts, matched amounts, and accrual balances are all held as Decimal from ingestion and quantized to two places with ROUND_HALF_UP so the arithmetic is exact and auditable.
What happens to a deduction that only partially matches an open accrual? The absorbable portion nets against the accrual and the deduction’s netted portion is marked settled, while the residual over-claim opens a child recovery record automatically. If the match is within tolerance the split happens without human intervention; if it breaches tolerance the whole deduction is held for manual review so an analyst can confirm the accrual coverage before any dollar is absorbed. The claimed amount is never mutated — netting and recovery produce derived balances that reference it.
How does the engine catch a retailer that is over-deducting systematically? Drift detection compares each retailer’s deduction rate — deductions as a percentage of invoiced value — against a rolling baseline, and flags a rate that climbs beyond tolerance quarter over quarter. It also watches reason-code skew, auto-net match-rate drops, aging breaches, and duplicate spikes. When a signal exceeds tolerance the engine holds the affected batch from auto-netting and raises an exception ticket, so a drifting retailer cannot gradually train the auto-net lane to accept larger unauthorized claims.
Related
- Accrual Posting & GL Integration — how netted deduction amounts post against accrual liabilities in the general ledger.
- Payment Settlement Reconciliation — matching vendor payments and short-pays to open accruals across the period.
- Month-End Close Sequencing — where deduction dispositions must land before the accrual freeze.
- Reconciliation Reporting Automation — surfacing deduction, recovery, and drift metrics in period-end reports.
- Routing Unauthorized Deductions for Recovery — packaging, evidencing, and pursuing invalid deductions through the recovery queue.
Up one level: Settlement, Deduction & Financial Close