Handling partial and failed reimbursement payments
When an ACH credit bounces back with an R-code, or a cross-border payment settles a few units short of the instructed amount, the reimbursement your ledger already marked as paid is not paid — and unless you detect the return, reverse the premature posting with a compensating entry, and re-queue the employee for a later run, the books drift out of sync with the bank and someone stays unpaid with nothing flagged. This guide is the failure-handling detail delegated by the Payment Batch Reconciliation topic area within the broader Reimbursement Routing & Approval Sync framework. The parent guide classifies each reconciled item; here we own what happens after an item comes back RETURNED or AMOUNT_MISMATCH — decoding NACHA return codes, writing the compensating transactions that keep the ledger consistent, and safely re-queueing recoverable payments without ever paying twice.
Why Standard Approaches Fail
Treating a failed payment as a rare exception to patch by hand produces three recurring defects.
- Premature posting with no reversal. A pay-and-forget system debits the reimbursement expense and credits cash the moment the file is sent. When the ACH credit returns two days later with
R01, nothing walks the entry back, so the ledger shows the employee paid while the bank shows the cash returned. The gap only surfaces at month-end close, by which point the trail is cold. - Blind retries that double-pay. A naive “just resend it” loop re-queues a returned payment without a stable idempotency key or an attempt cap. If the original return was a bank timing artifact and the first credit actually posted, the retry pays the employee twice; if the return code is terminal like
R03(no account), the retry bounces forever, burning a run slot every cycle. - Partial settlements dropped on the floor. A cross-border payment that settles a few units short after an FX fee is not a clean success or a clean failure. Systems that only branch on settled-versus-returned either post the full expected amount (overstating what the employee received) or reject the whole line (leaving the settled portion unrecorded). Neither reconciles, and the residual balance is silently lost.
The remedy is to classify every failure by its return code or variance, always post a compensating entry so the ledger tracks the bank exactly, and re-queue only the recoverable remainder under an idempotency key with a hard attempt cap.
Architecture & Algorithm
The handler maps a NACHA return code to a disposition — retryable, terminal, or account-fixable — then produces the compensating ledger entries and, where appropriate, a re-queued payout carrying an incremented attempt counter. Money stays in minor-unit integers throughout so partial balances never round.
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger("reimb.failed.classify")
class Disposition(str, Enum):
RETRYABLE = "RETRYABLE" # transient; re-queue same details
ACCOUNT_FIX = "ACCOUNT_FIX" # terminal; needs corrected bank details
MANUAL = "MANUAL" # unknown or capped; human handling
# NACHA return reason codes -> disposition. Only the common set is mapped;
# anything unmapped is MANUAL so an unknown code never auto-retries.
RETURN_CODE_MAP: dict[str, Disposition] = {
"R01": Disposition.RETRYABLE, # insufficient funds
"R09": Disposition.RETRYABLE, # uncollected funds
"R02": Disposition.ACCOUNT_FIX, # account closed
"R03": Disposition.ACCOUNT_FIX, # no account / unable to locate
"R04": Disposition.ACCOUNT_FIX, # invalid account number
"R16": Disposition.ACCOUNT_FIX, # account frozen
}
@dataclass(frozen=True)
class ReturnedItem:
payout_id: str
trace_id: str
expected_minor: int
settled_minor: int # 0 for a full return
return_code: str | None
attempt: int
def classify(item: ReturnedItem) -> Disposition:
"""Map a return code to a disposition; unknown codes never auto-retry."""
if item.return_code is None:
return Disposition.RETRYABLE if item.settled_minor < item.expected_minor \
else Disposition.MANUAL
disp = RETURN_CODE_MAP.get(item.return_code, Disposition.MANUAL)
logger.info("return_classified",
extra={"trace_id": item.trace_id, "code": item.return_code, "disp": disp.value})
return disp
With the disposition known, the handler emits compensating journal entries and decides whether to re-queue. Every posted disbursement is reversed by an equal-and-opposite entry so the net ledger position matches the bank, and only the unpaid remainder is re-queued — never the full amount, which would double-pay a partial settlement.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
logger = logging.getLogger("reimb.failed.handle")
MAX_ATTEMPTS = 3
@dataclass
class JournalEntry:
payout_id: str
account: str # "reimbursement_clearing" or "cash"
debit_minor: int
credit_minor: int
memo: str
@dataclass
class Resolution:
entries: list[JournalEntry] = field(default_factory=list)
requeue_minor: int = 0 # amount to pay in a later run (0 = none)
escalate: bool = False
def resolve_failure(item: ReturnedItem, disposition: Disposition) -> Resolution:
"""Produce compensating entries and an optional re-queue for a failed item.
The reversal always mirrors what was posted so the ledger equals the bank.
Only the shortfall (expected - settled) is re-queued, under an attempt cap.
"""
res = Resolution()
shortfall = item.expected_minor - item.settled_minor
# Reverse the previously-posted disbursement for the amount that did NOT land.
if shortfall > 0:
res.entries.append(JournalEntry(
payout_id=item.payout_id, account="cash",
debit_minor=shortfall, credit_minor=0,
memo=f"reversal of unsettled portion trace={item.trace_id}"))
res.entries.append(JournalEntry(
payout_id=item.payout_id, account="reimbursement_clearing",
debit_minor=0, credit_minor=shortfall,
memo=f"restore payable trace={item.trace_id}"))
if disposition is Disposition.ACCOUNT_FIX:
res.escalate = True # needs corrected bank details
elif disposition is Disposition.MANUAL:
res.escalate = True
elif disposition is Disposition.RETRYABLE:
if item.attempt + 1 >= MAX_ATTEMPTS:
res.escalate = True # capped: stop auto-retrying
logger.warning("retry_cap_reached", extra={"trace_id": item.trace_id})
else:
res.requeue_minor = shortfall # re-queue only the shortfall
logger.info("failure_resolved",
extra={"trace_id": item.trace_id, "requeue_minor": res.requeue_minor,
"escalate": res.escalate, "entry_count": len(res.entries)})
return res
Because the reversal is always the shortfall and the re-queue is always the shortfall, the two never disagree: what the ledger walks back is exactly what gets paid again. The re-queued amount re-enters batch assembly under a fresh idempotency key derived from payout_id plus the incremented attempt, so a later run cannot collide with the original send.
Step-by-Step Integration
-
Consume only non-settled items. Subscribe to the
RETURNEDandAMOUNT_MISMATCHoutcomes emitted by the reconciliation matcher in Reconciling reimbursement batches against bank settlement files; never re-process aSETTLEDline. -
Classify before you touch the ledger. Run
classifyfirst so an unknown or terminal code is escalated rather than blindly retried; only mapped retryable codes proceed to re-queue. -
Post compensating entries atomically. Write the reversal journal entries in the same transaction that flips the payout status, so the ledger can never show a reversed payment as still paid. Verify the resolution before wiring it live:
item = ReturnedItem(payout_id="P1", trace_id="T1", expected_minor=5000, settled_minor=0, return_code="R01", attempt=0) res = resolve_failure(item, classify(item)) assert res.requeue_minor == 5000 # full return, re-queue all assert sum(e.debit_minor for e in res.entries) == 5000 # reversal balances term = ReturnedItem("P2", "T2", 5000, 0, "R03", 0) assert resolve_failure(term, classify(term)).escalate # no account: escalate -
Re-queue the shortfall under a new idempotency key. Feed
requeue_minorback into batch assembly withattempt + 1; the derived key differs from the original send so the next run cannot double-pay. -
Escalate terminal and capped items to approval review. Route
escalateitems to correct bank details or obtain a manual decision through the Approval Chain Routing path before any further payment attempt. -
Post recovered payments through the ledger sync. When a re-queued item finally settles, post it via ERP Export Synchronization so the general ledger reflects the eventual successful disbursement, closing the loop opened by the reversal.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| Unknown return code | An unmapped R-code auto-retries and bounces forever | Default unmapped codes to MANUAL; never retry an unclassified return |
| Retry after a real settlement | A timing-artifact return triggers a resend that double-pays | Re-queue under a fresh idempotency key and reconcile before finalizing |
| Partial FX settlement | Posting full expected overstates what the employee received | Post the settled minor units, re-queue only expected - settled |
| Terminal code re-queued | R03 no-account loops every run, wasting slots |
Route ACCOUNT_FIX to approval review, never back to the run |
| Reversal without atomicity | Ledger shows paid while cash returned if reversal fails mid-write | Post reversal and status flip in one transaction |
| Attempt counter not incremented | Infinite retries on a persistently failing account | Cap at MAX_ATTEMPTS; escalate on the final attempt |
| Return arrives after finalize | A late R-code hits a run already marked reconciled | Keep runs provisional through the return window (parent guide policy) |
FAQ
What is the difference between a retryable and a terminal return code?
A retryable code signals a transient condition that may clear on its own — R01 insufficient funds and R09 uncollected funds can succeed on a later run once the receiving account has funds. A terminal code signals a structural problem with the destination — R02 account closed, R03 no account, and R04 invalid account number will fail identically every time until the bank details are corrected. Retryable codes re-queue automatically under an attempt cap; terminal codes escalate to fix the account rather than wasting run slots on a payment that cannot succeed.
Why post a compensating entry instead of just deleting the original posting?
Deleting or editing a posted entry destroys the audit trail and violates the append-only ledger that a compliance review depends on. A compensating entry leaves the original disbursement intact and adds an equal-and-opposite reversal, so the net position matches the bank while both the attempted payment and its reversal remain visible. An auditor can then reconstruct exactly what was sent, what came back, and what was re-paid, which a silent deletion makes impossible.
How do I re-queue a failed payment without paying the employee twice?
Re-queue only the unpaid shortfall — expected minus settled — and stamp it with a fresh idempotency key derived from the payout id plus the incremented attempt number. Because the key differs from the original send, the bank and the batch assembler both treat it as a distinct instruction, while the reversal you posted ensures the ledger reflects only one net payment. Never re-queue the full expected amount on a partial settlement, and always reconcile the new run before finalizing so a timing-artifact return cannot slip a second real payment through.
How should partial settlements be recorded?
Post the amount that actually settled as a completed disbursement in minor units, then create a re-queue for exactly the remaining balance so nothing is lost or overstated. The ledger shows the employee received the partial amount, the shortfall returns to the payable clearing account via the compensating entry, and the remainder rides the next run. This keeps the books equal to the bank at every step instead of forcing a partial into an all-or-nothing success or failure.
What happens when a payment exhausts its retry cap?
Once an item reaches MAX_ATTEMPTS it stops auto-retrying and escalates to manual handling, because a payment that has failed repeatedly is signalling a problem the automation cannot resolve — stale bank details, a frozen account, or a policy issue. The compensating reversal has already kept the ledger consistent, so escalation is about deciding the next action, not fixing the books. Routing the capped item through approval review, rather than looping it forever, is what stops a single bad account from silently consuming a run slot every cycle.
Related
- Payment Batch Reconciliation — the parent guide that classifies each reconciled item
- Reconciling reimbursement batches against bank settlement files — emits the returned and mismatched items this handler consumes
- ERP Export Synchronization — posts recovered payments and compensating entries to the ledger
- Automated Policy Validation & Anomaly Flagging — the upstream engine that clears records before they are paid