Matching Vendor Payments to Open Accruals
A remittance advice arrives from a vendor listing a lump payment against dozens of rebate claims, and the reconciliation engine has to decide, line by line, which open accrual each payment actually settles — without over-applying a cent, without re-clearing an accrual a duplicate remittance already closed, and without silently absorbing a currency it never converted. This page documents the exact procedure: normalize the remittance lines, generate candidate matches on (vendor, promotion, period), score and apply each line inside a decimal.Decimal tolerance, handle partial, over-, and under-payment by recording unapplied cash, and mark accruals cleared idempotently. It is the implementation-level companion to the Payment & Settlement Reconciliation topic area inside Settlement, Deduction & Financial Close, which owns the broader cash-application and close architecture.
Prerequisites
Before wiring payment-to-accrual matching into the close pipeline, confirm the following are in place:
- A parsed remittance contract. The vendor’s remittance advice — whether an EDI 820 payment order, a CSV export, or a bank lockbox file — must be reduced to structured lines carrying an amount, a currency, and enough context to key a match. Raw file parsing belongs upstream in the data ingestion normalization pipelines; this procedure consumes lines that are already one row per settled item.
- An open-accruals store. A queryable ledger of accruals in
OPENstate, each carrying anaccrual_id, the(vendor_id, promotion_id, period)it was posted under, anopen_amountin a knowncurrency, and the version of the terms that generated it. Accrual posting itself is owned by the accrual-posting-and-GL-integration topic area; matching reads its open balances and writes back clears. - A tolerance policy. A configured
decimal.Decimalepsilon — an absolute floor plus an optional relative component — that defines when a paid amount is “close enough” to an open accrual to settle it in full. The policy is data, not a hard-coded literal, so finance can tune it per vendor without a code change. - Python packages.
pydantic>=2.6and the standard-librarydecimalmodule. Every monetary field isdecimal.Decimal; neverfloat. - Access role. Write access to the
payment_applicationandunapplied_cashtables and read/update access to the open-accruals store — typically thereconciliation_etlservice role. Releasing suspense balances or overriding a cleared accrual requires a separate financial-override permission and is out of scope for this role.
Step-by-Step Implementation
Step 1 — Normalize the remittance lines
Parse each remittance row into a typed record before any matching begins. Normalization guarantees one currency, Decimal amounts, and a resolved key so that a downstream mismatch is a genuine reconciliation gap rather than a parsing artifact. The model rejects float amounts at the boundary.
from decimal import Decimal
from pydantic import BaseModel, field_validator
class RemittanceLine(BaseModel):
remittance_id: str # advice-level id, stable per file
line_no: int
vendor_id: str
promotion_id: str | None
period: str # ISO 'YYYY-MM' fiscal period
paid_amount: Decimal
currency: str # ISO 4217
@field_validator("paid_amount", mode="before")
@classmethod
def _reject_float(cls, v):
if isinstance(v, float):
raise ValueError("paid_amount must be a string/Decimal, not float")
return Decimal(str(v)).quantize(Decimal("0.01"))
@field_validator("currency")
@classmethod
def _iso4217(cls, v: str) -> str:
if len(v) != 3 or not v.isalpha():
raise ValueError(f"invalid currency code: {v!r}")
return v.upper()
Validation check: feed a line with paid_amount as a JSON float and assert it raises ValidationError; feed "12.5" and assert it quantizes to Decimal("12.50"). A remittance file that will not fully parse routes to quarantine, not to a partial application run.
Step 2 — Generate candidate matches on (vendor, promotion, period)
Query the open-accruals store for accruals that share the line’s natural key. Returning a list of candidates — rather than assuming a single row — is what lets Step 3 resolve a payment that spans several partial accruals or none at all. Order candidates deterministically so a replay produces the same application.
def candidates(line: RemittanceLine, store) -> list[dict]:
rows = store.open_accruals(
vendor_id=line.vendor_id,
promotion_id=line.promotion_id,
period=line.period,
)
same_ccy = [r for r in rows if r["currency"] == line.currency]
# deterministic: oldest open item first, then by accrual_id
return sorted(same_ccy, key=lambda r: (r["posted_at"], r["accrual_id"]))
Validation check: assert every returned candidate matches the line on (vendor_id, promotion_id, period) and currency, and that two calls with identical inputs return the same ordering. A line whose promotion_id is None should match only accruals posted without a promotion, never fall through to every open row for the vendor.
Step 3 — Score and apply within a Decimal tolerance
Compute the absolute gap between the paid amount and the candidate’s open balance and compare it to the configured epsilon using Decimal arithmetic end to end. When the gap is within tolerance, the accrual settles in full; otherwise the line is a partial or over-payment resolved in Step 4. The applied amount is never allowed to exceed the paid amount — that invariant is enforced in the model, not assumed.
from pydantic import BaseModel, model_validator
class TolerancePolicy(BaseModel):
abs_floor: Decimal = Decimal("0.01")
rel_pct: Decimal = Decimal("0.005") # 0.5% of the open balance
def epsilon(self, open_amount: Decimal) -> Decimal:
return max(self.abs_floor, (open_amount * self.rel_pct)).quantize(Decimal("0.01"))
class Application(BaseModel):
accrual_id: str
paid: Decimal
applied: Decimal
@model_validator(mode="after")
def _applied_le_paid(self) -> "Application":
if self.applied > self.paid:
raise ValueError("applied must be <= paid")
if self.applied < Decimal("0"):
raise ValueError("applied must be non-negative")
return self
def within_tolerance(paid: Decimal, open_amount: Decimal, policy: TolerancePolicy) -> bool:
return abs(paid - open_amount) <= policy.epsilon(open_amount)
Validation check: with open_amount = Decimal("1000.00") and the default policy, assert a paid amount of Decimal("1000.04") is within tolerance (epsilon 5.00) while Decimal("1006.00") is not. Construct an Application with applied greater than paid and assert it raises ValidationError.
Step 4 — Handle partial, over-, and under-payment and record unapplied cash
Only the within-tolerance case settles cleanly. A short-pay applies the paid amount and leaves the residual accrual open; an over-pay applies up to the open balance and books the excess to unapplied cash; a line with no candidate books its whole amount to unapplied cash. Suspense is a first-class ledger entry, never a rounding sink — the sum of applied plus unapplied always equals the paid amount.
def apply_line(line: RemittanceLine, cand: dict | None, policy: TolerancePolicy) -> dict:
paid = line.paid_amount
if cand is None: # no match at all
return {"applied": Decimal("0.00"), "unapplied": paid,
"accrual_id": None, "clear": False}
open_amt = cand["open_amount"]
if within_tolerance(paid, open_amt, policy): # full settle
applied = open_amt
else:
applied = min(paid, open_amt) # short- or over-pay
unapplied = (paid - applied).quantize(Decimal("0.01"))
Application(accrual_id=cand["accrual_id"], paid=paid, applied=applied) # invariant guard
return {
"accrual_id": cand["accrual_id"],
"applied": applied,
"unapplied": unapplied, # >0 only on over-pay / no-match
"residual_open": (open_amt - applied).quantize(Decimal("0.01")),
"clear": (open_amt - applied) <= policy.epsilon(open_amt),
}
Validation check: assert applied + unapplied == paid for every branch. For a paid = 900 against an open_amount = 1000, assert applied == 900, unapplied == 0, residual_open == 100, and clear is False. For paid = 1100 against 1000, assert applied == 1000, unapplied == 100, and clear is True.
Step 5 — Mark accruals cleared idempotently
Write the application and the clear under a stable application_hash derived from the immutable coordinates of the event, so replaying the same remittance file is a no-op rather than a double-clear. The store’s write is a conditional upsert keyed on that hash; if the row already exists, nothing changes.
import hashlib, json
from datetime import datetime, timezone
def application_hash(line: RemittanceLine, result: dict) -> str:
coords = {
"remittance_id": line.remittance_id,
"line_no": line.line_no,
"accrual_id": result["accrual_id"],
"applied": str(result["applied"]),
}
return hashlib.sha256(json.dumps(coords, sort_keys=True).encode()).hexdigest()
def commit(line: RemittanceLine, result: dict, store) -> dict:
h = application_hash(line, result)
if store.application_exists(h):
return {"status": "noop", "application_hash": h} # replay-safe
record = {
"application_hash": h,
"accrual_id": result["accrual_id"],
"applied": str(result["applied"]),
"unapplied": str(result["unapplied"]),
"cleared": result["clear"],
"ts": datetime.now(timezone.utc).isoformat(),
}
store.write_application(record) # conditional on hash
if result["clear"] and result["accrual_id"]:
store.set_accrual_state(result["accrual_id"], "CLEARED", via=h)
if result["unapplied"] > Decimal("0"):
store.book_unapplied_cash(line.remittance_id, line.line_no, result["unapplied"])
return {"status": "applied", "application_hash": h}
Validation check: run commit twice with identical inputs and assert the second call returns {"status": "noop"} and leaves the accrual state and unapplied-cash balance unchanged. Assert a CLEARED accrual is never re-opened by a later application carrying the same hash.
Common Failure Modes and Fixes
- Float tolerance drift. Computing
abs(paid - open)withfloatlets rounding error nudge a payment across the epsilon boundary inconsistently across platforms, so the same line clears on one run and short-pays on the next. Fix: keeppaid,open_amount, and the epsilon indecimal.Decimal, quantize toDecimal("0.01"), and derive the tolerance from the policy object rather than a literal. - Over-application past the open balance. Applying the full paid amount to an accrual whose open balance is smaller posts a negative balance and overstates settlement. Fix: clamp
applied = min(paid, open_amount), enforce theapplied <= paidinvariant in theApplicationmodel (Step 3), and route the excess to unapplied cash instead of the accrual. - Duplicate remittance re-clear. Re-ingesting the same advice file — a common lockbox retransmission — clears an already-settled accrual twice and double-counts the cash. Fix: derive a stable
application_hashfrom(remittance_id, line_no, accrual_id, applied)and make the write a conditional upsert; a repeat is a no-op (Step 5). - Currency mismatch. Matching a USD payment line against an EUR-posted accrual silently settles the wrong liability at a 1:1 rate. Fix: filter candidates on
currencyequality in Step 2 and validate the ISO 4217 code at ingestion; a cross-currency line requires an explicit FX conversion step before it is eligible to match, and until then routes to suspense. - Orphan unapplied cash. Booking an over-pay excess or a no-match amount without a back-reference leaves suspense balances that never tie out at close. Fix: always book unapplied cash with its
(remittance_id, line_no)origin so it reconciles as an open item, and surface aging suspense to the reconciliation reporting automation exception report rather than letting it absorb into a variance.
Operational Checklist
Frequently Asked Questions
How is the matching tolerance chosen, and why not require an exact cent match?
Vendors round rebate lines, net small deductions, and apply their own FX at slightly different precision, so an exact-cent rule would leave a large fraction of genuinely-settled accruals open. The epsilon combines an absolute floor with a relative component of the open balance, both in decimal.Decimal, so a small accrual settles on a tight tolerance while a large one allows proportionally more slack. The policy is data, so finance tunes it per vendor without a deployment.
What happens to a payment line that matches no open accrual?
Its full amount books to unapplied cash with its (remittance_id, line_no) origin preserved, and it appears as an open suspense item on the reconciliation report. It is never absorbed into a variance or dropped. When the missing accrual is later posted — or the correct promotion is identified — the suspense balance is released and applied under a fresh application hash.
Why enforce idempotency with a hash instead of a processed-file flag?
A file-level flag cannot survive partial reprocessing, split retransmissions, or a line that moves between files. Hashing the immutable coordinates of each application — remittance, line, accrual, and applied amount — makes the unit of idempotency the individual clear, so any replay at any granularity resolves to a no-op and a CLEARED accrual is never settled twice.
How does a short-pay differ from an unauthorized deduction? A short-pay is a payment smaller than the open accrual that still applies cleanly and leaves a residual open balance for the next remittance. An unauthorized deduction is the vendor withholding cash it was not entitled to, which is a recovery case rather than a settlement; those route to deduction & short-pay routing for adjudication instead of quietly reducing the accrual.
Related
- Parent cluster: Payment & Settlement Reconciliation — the cash-application and settlement topic area this procedure sits inside.
- Deduction & Short-Pay Routing — where a withheld or unauthorized short-pay is adjudicated for recovery rather than settled.
- Reconciliation Reporting Automation — the exception and aging reports that surface open items and orphan unapplied cash at close.
- Data Ingestion & Normalization Pipelines — the upstream parsing that reduces a raw remittance file to the normalized lines this step consumes.
Up one level: Payment & Settlement Reconciliation