Skip to content

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.

Applying a normalized remittance line to an open accrual under a decimal tolerance A normalized remittance line enters candidate matching keyed on vendor, promotion, and period. A line with no candidate routes straight to an unapplied-cash suspense bucket. A matched line reaches a compare stage that measures the absolute difference between the paid amount and the open accrual against a decimal tolerance epsilon. Three outcomes branch from the compare stage: within tolerance applies the full amount and marks the accrual cleared; a short-pay applies the paid amount and leaves the residual accrual open; an over-pay applies up to the open accrual balance and sends the excess to the same unapplied-cash bucket. Every clear is idempotent, keyed on a stable application hash so a replayed remittance is a no-op. Normalized remittance line one currency · Decimal amounts · UOM resolved Candidate match key = (vendor · promotion · period) no candidate Compare paid vs open accrual | paid − open | ≤ ε (Decimal tolerance) within ε paid < open paid > open = Full match apply full amount mark accrual cleared Short-pay apply paid amount residual accrual stays open + Over-pay apply to open balance excess → unapplied cash Unapplied cash · suspense reconciles to open items · never a silent absorb Every clear is idempotent — keyed on a stable application_hash, so a replayed remittance is a no-op.

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 OPEN state, each carrying an accrual_id, the (vendor_id, promotion_id, period) it was posted under, an open_amount in a known currency, 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.Decimal epsilon — 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.6 and the standard-library decimal module. Every monetary field is decimal.Decimal; never float.
  • Access role. Write access to the payment_application and unapplied_cash tables and read/update access to the open-accruals store — typically the reconciliation_etl service 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.

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

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

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

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

python
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

  1. Float tolerance drift. Computing abs(paid - open) with float lets 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: keep paid, open_amount, and the epsilon in decimal.Decimal, quantize to Decimal("0.01"), and derive the tolerance from the policy object rather than a literal.
  2. 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 the applied <= paid invariant in the Application model (Step 3), and route the excess to unapplied cash instead of the accrual.
  3. 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_hash from (remittance_id, line_no, accrual_id, applied) and make the write a conditional upsert; a repeat is a no-op (Step 5).
  4. 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 currency equality 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.
  5. 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.

Up one level: Payment & Settlement Reconciliation