Short-Pay & Deduction Management
Short-paying a carrier is the point where an audit stops being an opinion and becomes money. Once the validation tier has proven that a billed charge exceeds the contracted charge, the program has two ways to recover the variance: raise a dispute and wait, or remit the invoice net of the overcharge and attach a reason the carrier can reconcile against. Short-pay is the second path, and it is the faster one — but only when it is controlled. An uncontrolled short-pay program pays a different amount than it can justify, tags the deduction with a code the carrier’s AR team does not recognize, and never records whether the deduction was accepted or clawed back. Within a quarter the carrier’s statement and the internal ledger have diverged by tens of thousands of dollars that nobody can reconstruct, and every one of those deductions has quietly aged into a dispute that now needs a human.
This part of the pipeline sits between the audit verdict and accounts payable. It consumes an approved variance — a discrepancy that has already been classified and cleared for recovery — and turns it into a deduction line the AP system can post: an amount, a standardized reason code the carrier’s remittance-advice matching will accept, and a tracking record that follows the short-pay from booking to resolution or reversal. It does not decide whether a charge is wrong; that judgment is made upstream in Charge Variance Classification. Its job is to make the deduction legible, bounded by carrier policy, and impossible to lose. Everything here plugs into the wider Discrepancy Resolution & Dispute Routing architecture and writes its outcomes back to the shared ledger.
Prerequisites & inputs
This stage is a pure transform over an approved-variance stream. It owns no audit logic and no payment execution — it produces deduction records and an AP export, and it reads back carrier responses to close the loop.
| Input / dependency | Source | Contract |
|---|---|---|
| Approved variance record | Charge Variance Classification | variance_type, billed_amount, contracted_amount, carrier_scac, invoice_number |
| Carrier deduction policy | Vendor master / contract store | allow_short_pay, min_deduction, max_deduction_pct, net_terms_days |
| Ledger handle | Reconciliation Ledger Schema | Append-only writer keyed by deduction_id |
| Carrier response feed | AP remittance / Carrier Dispute API Integration | accepted / rejected with a carrier reference |
| Python dependency | Minimum version | Purpose |
|---|---|---|
python |
3.10+ | match statements, dataclasses, modern typing |
decimal (stdlib) |
— | Exact deduction arithmetic; never float on money |
csv (stdlib) |
— | Positional/CSV AP export writer |
enum (stdlib) |
— | Deduction lifecycle state machine |
The one input contract that matters is approval. A variance that has not been cleared for recovery must never reach this stage — a short-pay booked against an unconfirmed charge is an unauthorized deduction, and unwinding it costs more goodwill than the recovery was worth. The approval gate lives upstream; this stage asserts it and refuses anything that fails the assertion.
Architecture: from approved variance to booked deduction
The data flow is a straight line with one branch at the end. An approved variance is assigned a deduction reason code, the code and amount are written to the AP export, the carrier responds on their remittance advice, and the short-pay either resolves (the deduction stands) or reverses (the carrier rebuts it and the withheld dollars are re-issued). Every transition is written to the reconciliation ledger so the deduction is auditable end to end.
Deduction reason-code mapping
The single most common failure in a short-pay program is a code the carrier’s AR team cannot map back to a line on their own invoice. Carriers reconcile deductions against a small, standardized vocabulary; a free-text “audit adjustment” gets kicked back or held in their suspense account, and the short-pay ages. The fix is a fixed map from the internal variance vocabulary produced upstream to a standardized AP deduction code, so every analyst — and every automated run — codes the same variance identically.
| Internal variance type | What it means | AP deduction code | Carrier-facing label | Typically reversible |
|---|---|---|---|---|
OVERCHARGE_LINEHAUL |
Billed linehaul above contracted rate | D100 |
Rate adjustment — linehaul | Yes |
FSC_DRIFT |
Fuel surcharge diverges from indexed formula | D110 |
Fuel surcharge variance | Yes |
WEIGHT_RERATE |
Billable weight exceeds reweighed actual | D120 |
Reweigh / billable-weight adjustment | Yes |
MIN_CHARGE_MISAPPLIED |
Minimum charge applied above tariff floor | D130 |
Minimum charge correction | Yes |
ACCESSORIAL_UNSUPPORTED |
Accessorial billed with no supporting event | D200 |
Unsupported accessorial removed | Yes |
DUPLICATE_BILL |
Same movement invoiced twice | D300 |
Duplicate invoice — full withhold | No |
The Typically reversible column is not decoration. A D300 duplicate is a full withhold of a second invoice for a movement already paid; there is nothing to negotiate, so a carrier rebuttal on a duplicate is escalated rather than auto-reversed. A D100 rate adjustment, by contrast, can legitimately be reversed if the carrier produces a contract amendment the audit did not have. The reversal lifecycle and the per-carrier translation of these codes are the subject of the child page below.
Carrier deduction policies
Not every carrier accepts a short-pay, and the ones that do impose floors and ceilings. A deduction below a carrier’s minimum is more expensive to process than to forgive; a deduction above a ceiling percentage trips their AR review and stalls the whole invoice. The policy table is read per carrier before any deduction is booked, and the amount is clamped — or the whole recovery is re-routed to a formal dispute — accordingly.
| Policy key | Type | Meaning | Effect when violated |
|---|---|---|---|
allow_short_pay |
bool | Carrier permits deduction at time of payment | Route to dispute instead of short-pay |
min_deduction |
Decimal | Smallest deduction worth booking | Suppress; write off below floor |
max_deduction_pct |
Decimal | Ceiling as a fraction of invoice total | Cap deduction; dispute the remainder |
net_terms_days |
int | Days before an open short-pay is aged | Escalate when exceeded |
A carrier flagged allow_short_pay = false is a hard fork: the variance is still valid, but recovery has to go through Carrier Dispute API Integration as a claim rather than a withhold. Mixing the two — short-paying a carrier that has contractually refused deductions — is the fastest way to a chargeback and a strained relationship.
Step-by-step implementation
Step 1 — Model the deduction record
The deduction record is the unit of tracking. It carries the money, the code, the carrier, a deterministic id so redelivery cannot double-book, and a lifecycle state. Everything downstream keys off this record, so it is immutable except through explicit state transitions.
# deductions/model.py
import enum
import hashlib
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
class DeductionState(str, enum.Enum):
OPEN = "OPEN" # booked, awaiting carrier response
RESOLVED = "RESOLVED" # carrier accepted; short-pay is final
REVERSED = "REVERSED" # carrier rebutted; withheld amount re-issued
ESCALATED = "ESCALATED" # rebuttal on a non-reversible code
@dataclass(frozen=True)
class Deduction:
carrier_scac: str
invoice_number: str
variance_type: str
reason_code: str
billed_amount: Decimal
deducted_amount: Decimal # what we actually short-pay, post-clamp
state: DeductionState = DeductionState.OPEN
booked_on: date = field(default_factory=date.today)
@property
def deduction_id(self) -> str:
"""Stable id — identical for every redelivery of one short-pay."""
raw = f"{self.carrier_scac.upper()}|{self.invoice_number}|{self.reason_code}"
return hashlib.sha256(raw.encode()).hexdigest()[:20]
@property
def net_payable(self) -> Decimal:
return self.billed_amount - self.deducted_amount
Common mistake: deriving the deduction_id from a timestamp or an auto-increment. When the same approved variance is re-processed after a retry, a time-based id books a second short-pay against an invoice already reduced, and the carrier is now underpaid twice for one overcharge. Key the id off the invoice and code so redelivery collapses to one record.
Step 2 — Assign the reason code deterministically
Reason-code assignment is a lookup, not a judgment call. A single frozen map turns the internal variance type into a standardized AP code, and the carrier policy clamps the amount. The function refuses an unmapped variance type outright — a code invented on the fly is exactly the code the carrier will reject.
# deductions/assign.py
from decimal import Decimal
from deductions.model import Deduction, DeductionState
REASON_CODES = {
"OVERCHARGE_LINEHAUL": "D100",
"FSC_DRIFT": "D110",
"WEIGHT_RERATE": "D120",
"MIN_CHARGE_MISAPPLIED": "D130",
"ACCESSORIAL_UNSUPPORTED": "D200",
"DUPLICATE_BILL": "D300",
}
class UnroutableDeduction(Exception):
"""Recovery cannot be booked as a short-pay; route to a formal dispute."""
def assign_deduction(variance: dict, policy: dict) -> Deduction:
vtype = variance["variance_type"]
if vtype not in REASON_CODES:
# Never mint a code the carrier's AR team has never seen.
raise UnroutableDeduction(f"no standard code for variance {vtype!r}")
if not policy.get("allow_short_pay", False):
raise UnroutableDeduction(f"{variance['carrier_scac']} refuses deductions")
billed = Decimal(str(variance["billed_amount"]))
contracted = Decimal(str(variance["contracted_amount"]))
overcharge = billed - contracted
# Clamp to the carrier ceiling; the remainder becomes a dispute, not a short-pay.
ceiling = billed * Decimal(str(policy.get("max_deduction_pct", "1")))
deducted = min(overcharge, ceiling)
floor = Decimal(str(policy.get("min_deduction", "0")))
if deducted < floor:
raise UnroutableDeduction(f"deduction {deducted} below floor {floor}")
return Deduction(
carrier_scac=variance["carrier_scac"],
invoice_number=variance["invoice_number"],
variance_type=vtype,
reason_code=REASON_CODES[vtype],
billed_amount=billed,
deducted_amount=deducted,
state=DeductionState.OPEN,
)
Common mistake: clamping the amount but silently dropping the clamped remainder. If a $900 overcharge is capped at an $800 deduction ceiling, the remaining $100 does not vanish — it is a residual claim that must be raised as a dispute, or the audit under-recovers by exactly the clamp.
Step 3 — Write the AP deduction export
The export is the hand-off to accounts payable. It is a positional or delimited file the ERP imports, one row per short-pay, carrying the net payable, the deduction amount, and the standardized code so the carrier’s remittance advice can match it. Money is written as fixed-scale strings — a float here is the last place precision leaks before it hits the payment file.
# deductions/export.py
import csv
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable
from deductions.model import Deduction
CENTS = Decimal("0.01")
def _money(value: Decimal) -> str:
"""Fixed two-scale string; never emit a float into an AP file."""
return str(value.quantize(CENTS, rounding=ROUND_HALF_UP))
def write_ap_export(deductions: Iterable[Deduction], path: str) -> int:
"""Emit one AP short-pay line per deduction. Returns rows written."""
rows = 0
with open(path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow([
"deduction_id", "carrier_scac", "invoice_number",
"reason_code", "billed_amount", "deduction_amount", "net_payable",
])
for d in deductions:
writer.writerow([
d.deduction_id, d.carrier_scac, d.invoice_number,
d.reason_code, _money(d.billed_amount),
_money(d.deducted_amount), _money(d.net_payable),
])
rows += 1
return rows
Common mistake: exporting the deduction amount as a positive number in a column the ERP treats as a credit, so the short-pay is applied as an additional charge and the carrier is paid more than billed. Agree the sign convention with AP once and assert it in a test, because this failure is invisible until the carrier calls about an overpayment.
Validation & testing
Because assignment and export are pure functions, they are cheap to test exhaustively, and the cases that matter are the policy boundaries — the clamp, the floor, the refused carrier — not the happy path.
# tests/test_deductions.py
import pytest
from decimal import Decimal
from deductions.assign import assign_deduction, UnroutableDeduction
from deductions.model import DeductionState
POLICY = {"allow_short_pay": True, "min_deduction": "5.00", "max_deduction_pct": "0.5"}
def _variance(**kw):
base = {
"variance_type": "OVERCHARGE_LINEHAUL", "carrier_scac": "EXLA",
"invoice_number": "INV-4471", "billed_amount": "1200.00",
"contracted_amount": "1000.00",
}
base.update(kw)
return base
def test_assigns_standard_code_and_amount():
d = assign_deduction(_variance(), POLICY)
assert d.reason_code == "D100"
assert d.deducted_amount == Decimal("200.00")
assert d.state is DeductionState.OPEN
def test_clamps_to_carrier_ceiling():
# 0.5 * 1200 = 600 ceiling; a 900 overcharge is capped to 600.
d = assign_deduction(_variance(contracted_amount="300.00"), POLICY)
assert d.deducted_amount == Decimal("600.00")
def test_unknown_variance_is_unroutable():
with pytest.raises(UnroutableDeduction):
assign_deduction(_variance(variance_type="MYSTERY"), POLICY)
def test_deduction_id_is_stable_across_redelivery():
a = assign_deduction(_variance(), POLICY)
b = assign_deduction(_variance(), POLICY)
assert a.deduction_id == b.deduction_id # idempotent booking
The fixture matrix worth keeping covers: an overcharge below the floor (suppressed), a carrier with allow_short_pay = false (unroutable), a duplicate that must not be reversible, and two redeliveries of one variance that must collapse to a single deduction_id.
Performance & tuning
Assignment and export are CPU-cheap; a batch of tens of thousands of deductions codes and writes in well under a second. The cost is entirely in the ledger writes and the AP hand-off, so tune the batch, not the arithmetic.
| Knob | Typical range | Effect | Watch for |
|---|---|---|---|
| AP export batch size | 1,000–5,000 rows | Amortizes ERP import overhead | Partial import on a mid-file error — write atomically |
| Ledger commit chunk | 250–1,000 | Fewer transactions vs. lock duration | Long transactions blocking remittance matching |
| Aging sweep interval | 6–24 h | How fast open short-pays escalate | Missed net_terms_days if the sweep lags |
| Policy cache TTL | 15–60 min | Reuses vendor-master policy | Stale ceiling after a mid-cycle policy change |
The load-bearing number is the aging interval. A short-pay that sits OPEN past the carrier’s net_terms_days is, by definition, becoming a dispute; the sweep that flips it to ESCALATED is what keeps the deduction book from silently rotting.
Failure modes
-
Unauthorized deduction. A short-pay is booked against a variance that was never approved, or against a carrier flagged
allow_short_pay = false. Diagnostic: a deduction whose upstream approval flag is absent, or aD-code line for a refused SCAC. Resolution: assert approval inassign_deductionand fork refused carriers to a dispute; never let an unapproved variance reach the export. -
Code the carrier won’t accept. A deduction carries a free-text or non-standard reason the carrier’s AR cannot map, so it lands in their suspense account and ages. Diagnostic: rebuttals clustered on one reason string; deductions unacknowledged past
net_terms_days. Resolution: restrict codes to theREASON_CODESmap and translate per carrier (see the child page); reject anything unmapped at assignment time. -
Reversal not booked. A carrier rebuts a deduction, AP re-issues the withheld amount, but the ledger still shows the short-pay as
OPENorRESOLVED— so the recovery is double-counted and the ledger overstates savings. Diagnostic: a re-issued payment with no matchingREVERSEDtransition. Resolution: make the response handler post aREVERSEDentry atomically with the re-issue, reconciled against carrier remittance. -
Clamp leak. A deduction capped at the carrier ceiling drops its remainder, so the audit recovers less than it proved. Diagnostic:
deducted_amountstrictly below the computed overcharge with no residual dispute. Resolution: raise the clamped remainder as a dispute rather than discarding it. -
Double-book on redelivery. The same approved variance is processed twice and books two short-pays. Diagnostic: two deductions with the same
carrier_scac + invoice_number + reason_code. Resolution: key the record on the deterministicdeduction_idso the second booking is a no-op.
Integration points
The output of this stage is a stable deduction record plus its AP export line, and every state transition it makes is a ledger entry. The contract with the Reconciliation Ledger Schema is exact: a booked short-pay writes a debit against the carrier’s payable, a resolution finalizes it, and a reversal writes the offsetting credit — so the ledger’s running balance always equals recovered-minus-reversed with no reconstruction needed. Short-pays that a carrier rebuts on a non-reversible code hand off to Carrier Dispute API Integration as formal claims, and the variance vocabulary the whole stage keys on originates in Charge Variance Classification. Because this stage never re-audits and never pays, its integration boundary is clean: approved variances in, coded deductions and ledger transitions out.
In this section
- Automating Short-Pay Deduction Codes in Python — the hands-on build for the code layer: a deterministic canonical-code mapper so the same variance always gets the same code, a per-carrier translation table so carriers only ever see codes they recognize, and a reversal state machine so rebutted deductions are booked back instead of leaking recovered dollars.
Related
- Discrepancy Resolution & Dispute Routing — the parent architecture this short-pay stage plugs into.
- Charge Variance Classification — produces the approved variance vocabulary every deduction is coded from.
- Reconciliation Ledger Schema — the append-only ledger each booking, resolution, and reversal posts to.
- Carrier Dispute API Integration — where refused-carrier recoveries and non-reversible rebuttals become formal claims.