Skip to content

Payment & Settlement Reconciliation

A rebate accrual is only a claim on cash until a vendor actually pays it, and the moment money lands the reconciliation is not over — it is at its most fragile. Incoming payments arrive as a bank credit plus a remittance advice that names, in the vendor’s own vocabulary, which claims the cash is meant to settle. Those references rarely align cleanly with the open accruals the ledger is carrying: one wire covers eleven promotions across three periods, an amount is short by a deduction the vendor took unilaterally, a currency differs from the accrued one, or the remittance names a claim number the system has never seen. Within the broader settlement, deduction & financial close discipline, this page owns the specific problem of turning that noisy inflow into a deterministic, auditable set of applications against open accruals — and clearing them exactly once.

This is the canonical reference for that matching-and-clearing stage. It describes the payment, remittance, and open-accrual schema the reconciler operates over, the N-way tolerance logic that pairs a payment line to the accrual it settles, the decimal.Decimal treatment of partial, over-, and under-payment across currencies, the Pydantic-enforced application patterns that make settlement safe to replay, and the drift, dispute, and access controls that let trade finance defend every cleared balance. It consumes the accruals produced upstream by core architecture / promotion mapping and validated by the claim validation rule engine, and it hands short payments sideways to deduction & short-pay routing. The audience is concrete: trade finance analysts who close the period, Python ETL developers who own the reconciler, and vendor managers who answer for an unapplied balance.

Payment-to-accrual matching and settlement flow A left-to-right settlement pipeline. An open accrual ledger sits at the top and feeds candidate accruals into the matcher. Incoming payments with their remittance advice enter at stage one, remittance ingest, where a content hash and idempotency key are computed. Stage two, candidate matching, performs N-way pairing of accrual, claim, and payment within a Decimal tolerance band. Stage three applies the matched cash to open accruals idempotently. From stage three the flow forks into three outcomes: fully applied and cleared with zero unapplied balance in teal, partial application with parked and aged unapplied cash in gold, and unmatched or short payment routed to the deduction workflow and manual review in red. An append-only, hash-chained audit log spans every stage. Open accrual ledger write-once accruals · open_balance · aging candidates ingest match 1 Remittance ingest payment & remittance advice content hash · dedup key 2 Candidate matching N-way: accrual ↔ claim ↔ payment Decimal tolerance band 3 Apply to accruals idempotent by application_id clear or part-apply within tolerance short / over no candidate Fully applied · cleared accrual open_balance → 0 unapplied_amount = 0 Partial · unapplied cash part-applied · residual parked on-account cash · aged Unmatched → review short-pay → deduction workflow no match → manual review Append-only, hash-chained audit log spans every stage — records each ingest · application · reversal · reprocess event

Positioning Within the Reconciliation Architecture

This stage sits at the terminal edge of the reconciliation pipeline, downstream of everything that produced a liability and upstream of the general-ledger close. It does not compute what a vendor owes — that figure was fixed when the accrual was posted — and it does not adjudicate whether a claim was valid. Its single responsibility is to observe cash actually received, attribute it to the specific open accruals it settles, and drive those balances to zero without ever crediting the same dollar twice. The inputs are two feeds that must be treated as separate facts: the payment, a confirmed movement of money with a value date and amount, and the remittance advice, the vendor’s stated intent about which claims that money covers. The two arrive by different channels and on different schedules — a bank credit today, an emailed remittance file tomorrow — and the reconciler must not assume they agree.

Three boundaries keep the stage honest. First, cash is never invented from a remittance: a remittance line that references a claim is only a hypothesis until a real payment backs it, so applications are driven by confirmed money, not by advice. Second, the accrual is authoritative for what was owed, and this stage may reduce an open balance but never re-rate it; a disagreement between paid and accrued is surfaced as a delta, not silently absorbed. Third, settlement is the last chance to catch leakage before it becomes permanent — an unapplied credit that ages into a write-off, or a short payment quietly cleared, is margin lost. Adjacent stages depend on this contract: accrual posting & GL integration needs a clean cleared/open split to journal, month-end close sequencing needs the reconciler to have quiesced before the accrual freeze, and reconciliation reporting automation reads the resulting application records to produce the period statement.

Entity Topology and Schema Specification

Settlement is only deterministic if the records it reasons over are stable and hashable. Four entities carry the state: the Payment (confirmed cash plus header metadata), the RemittanceLine (one claimed reference from the vendor’s advice), the OpenAccrual (a posted liability with a remaining balance), and the Application (the write-once fact that a slice of a payment settled a slice of an accrual). The Application is the crux — it is where idempotency lives — and its key must be assigned deterministically at match time, never inferred later. The table below specifies the minimum schema; treat it as a versioned interface, where adding a nullable field is backward-compatible while renaming or retyping one is a breaking change that bumps schema_version.

Field Type Entity Constraint
payment_id str (ULID) Payment immutable, primary key
remittance_ref str Payment vendor remittance number, dedupe input
vendor_id str Payment resolved, required
paid_amount Decimal Payment >= 0, scale 2, never float
currency str (ISO 4217) Payment as-received, not converted in place
value_date str (ISO-8601) Payment bank value date, UTC-anchored
applied_to list[Application] Payment ordered; sum(applied) <= paid_amount
unapplied_amount Decimal Payment paid_amount - Σ applied, >= 0
match_status enum Payment matched / partial / unmatched / disputed
line_ref str RemittanceLine vendor claim/deduction reference
claimed_amount Decimal RemittanceLine scale 2, may differ from paid
accrual_id str (ULID) OpenAccrual primary, references posted accrual
open_balance Decimal OpenAccrual remaining, >= 0, scale 2
accrual_age_days int OpenAccrual derived from post date, drives aging
application_id str (SHA-256) Application idempotency key, deterministic
applied_amount Decimal Application > 0, <= open_balance and <= unapplied

The application_id is what makes the whole stage safe to replay: it is a deterministic hash over (payment_id, accrual_id, remittance_ref, applied_amount), so re-processing an unchanged remittance produces the identical key and the persistence layer treats the second write as a no-op rather than a second clearing. The unapplied_amount is a derived invariant, never a free field — it is always paid_amount minus the sum of applications, so a stored value that disagrees with that sum is itself a drift signal. Every monetary column is held as Decimal from ingestion onward; the moment a payment amount touches float the reconciliation is no longer reproducible to the cent, and settlement is the one place where the ledger cannot tolerate that.

Conditional Logic and Rule Integration

Before any cash is applied, each payment passes through matching, which decides which open accruals a remittance line can legitimately settle and whether the amounts are close enough to clear. Matching is N-way, not a single join, because three independent records have to agree: the vendor’s remittance line, the claim it references, and the open accrual the claim generated. A clean pair is the exception; the rule layer exists for everything else.

Reference resolution. The first predicate resolves line_ref to an accrual_id. Vendors reference claims by their own claim number, an invoice number, a promotion code, or a deduction number, so resolution walks a chain of candidate keys rather than assuming one canonical identifier. When a line resolves to more than one open accrual — a common case where one deduction spans several promotions — the matcher produces a candidate set and defers the split to the settlement layer, rather than forcing an arbitrary one-to-one pairing.

Tolerance-based amount matching. Amounts almost never agree to the cent. A vendor rounds, applies a settlement discount, or nets a freight charge, so the matcher compares claimed_amount (and the cash behind it) against open_balance within an explicit tolerance band expressed in Decimal. Tolerance is two-sided and configurable per vendor: an absolute floor (e.g. Decimal("2.00")) below which a residual is written to a rounding account, and a relative ceiling (e.g. 0.5% of the accrual) above which the difference is too large to absorb and becomes a dispute. This mirrors the deterministic, precedence-ordered predicate discipline of the eligibility rule framework: a match is full, within_tolerance, or out_of_tolerance, and only the first two ever clear an accrual.

Directional classification. The sign and size of the gap decide the route. A payment at or above the accrual (within the over-tolerance) clears it and parks any excess as on-account cash; a payment short of the accrual by more than tolerance is a short payment and is handed to the deduction workflow rather than cleared; a remittance line that resolves to no open accrual at all is unmatched and routed to manual review. The matcher never guesses across this boundary — it emits the classification and the settlement layer acts on it.

json
{
  "matcher": {
    "reference_keys": ["claim_no", "invoice_no", "promo_code", "deduction_no"],
    "tolerance": {"absolute": "2.00", "relative_pct": "0.5", "currency": "USD"},
    "on_multi_candidate": "defer_split",
    "classify": ["full", "within_tolerance", "short_payment", "over_payment", "unmatched"]
  }
}

Financial Settlement Layer

Matching decides intent; the settlement layer moves the numbers, and this is where precision is either preserved or irreversibly lost. Every amount — paid_amount, open_balance, applied_amount, the residual — is a decimal.Decimal carried from ingestion with an explicit scale and rounding mode. The arithmetic is deliberately conservative: an application can never exceed either the remaining cash on the payment or the remaining balance on the accrual, so the amount actually applied is the minimum of the two, and both sides are decremented atomically. This is what prevents a payment that touches three accruals from over-applying against the second because the first left too little cash.

Partial, over-, and under-payment are the normal cases, not edge cases. A partial payment applies what cash exists and leaves the accrual open with a reduced balance that ages from its original post date, not from the payment date — so a stale liability cannot be laundered into a fresh one by a token payment. An over-payment clears the accrual and books the excess as unapplied on-account cash, which itself becomes an aging item that must be resolved rather than forgotten. A short-payment within tolerance clears the accrual and writes the small residual to a rounding account; outside tolerance it does not clear — the gap is routed as a deduction. Multi-currency is handled by refusing to net across currencies inside an application: a USD payment never partially clears an EUR accrual by silent conversion. When currencies differ, the reconciler records the conversion as an explicit, rate-stamped step so the FX difference is an auditable line rather than a rounding artefact.

python
from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

def apply_payment(unapplied: Decimal, open_balance: Decimal,
                  tol_abs: Decimal, tol_rel: Decimal) -> dict:
    """Apply cash to one open accrual. Never over-applies either side.
    Returns the applied amount, the residual, and a settlement disposition."""
    gap = (open_balance - unapplied).quantize(CENT, rounding=ROUND_HALF_UP)
    ceiling = (open_balance * tol_rel).quantize(CENT, rounding=ROUND_HALF_UP)

    applied = min(unapplied, open_balance).quantize(CENT, rounding=ROUND_HALF_UP)
    new_open = (open_balance - applied).quantize(CENT, rounding=ROUND_HALF_UP)
    new_unapplied = (unapplied - applied).quantize(CENT, rounding=ROUND_HALF_UP)

    if new_open == Decimal("0.00"):
        disp = "cleared"
    elif new_open <= max(tol_abs, ceiling):
        disp = "cleared_rounding"          # residual to rounding account
        new_open = Decimal("0.00")
    else:
        disp = "short_payment"             # remaining gap -> deduction workflow
    return {
        "applied_amount": applied,
        "new_open_balance": new_open,
        "unapplied_amount": new_unapplied,  # > 0 means on-account cash
        "gap": gap,
        "disposition": disp,
    }

The disposition returned here is the contract the routing layer acts on, and the residual math mirrors the flat/incremental/retroactive precision the payout structure modeling layer applies when the accrual was first computed — the same Decimal context, so a value posted at accrual time reconciles to the cent at settlement time.

ETL Implementation Patterns

Enforcement begins at the model boundary. Represent the settlement records with Pydantic v2 so a float amount, a negative application, or an over-application fails fast at the point of matching instead of surfacing as a phantom cleared balance at close. The two validators below reject the corruptions that hand-rolled reconcilers most often miss: an amount that arrived as a binary float, and an application set whose total exceeds the cash on the payment.

python
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, field_validator, model_validator

class MatchStatus(str, Enum):
    MATCHED = "matched"
    PARTIAL = "partial"
    UNMATCHED = "unmatched"
    DISPUTED = "disputed"

class Application(BaseModel):
    application_id: str
    accrual_id: str
    applied_amount: Decimal

    @field_validator("applied_amount", mode="before")
    @classmethod
    def _decimal_only(cls, v: object) -> Decimal:
        if isinstance(v, float):
            raise ValueError("money must arrive as str or Decimal, never float")
        d = Decimal(str(v))
        if d <= Decimal("0"):
            raise ValueError("applied_amount must be strictly positive")
        return d

class Payment(BaseModel):
    payment_id: str
    remittance_ref: str
    vendor_id: str
    paid_amount: Decimal
    currency: str
    applied_to: list[Application] = []

    @model_validator(mode="after")
    def _applied_within_paid(self) -> "Payment":
        total = sum((a.applied_amount for a in self.applied_to), Decimal("0"))
        if total > self.paid_amount:
            raise ValueError("applications exceed paid_amount — over-application")
        return self

    @property
    def unapplied_amount(self) -> Decimal:
        return self.paid_amount - sum(
            (a.applied_amount for a in self.applied_to), Decimal("0")
        )

Persistence uses idempotent upserts keyed on application_id, so a remittance re-sent by a vendor — or replayed after a worker crash mid-batch — overwrites the identical application rather than clearing the same accrual a second time. Whole-file duplicate remittances are caught even earlier by comparing the content hash of the advice before a single line is matched. Because an accrual can be touched by several payments over time, the reconciler never mutates open_balance in place from a computed guess; it recomputes the balance as the posted amount minus the sum of committed applications, so the ledger balance is always a function of the immutable application log and can be rebuilt from it. The transaction-set-specific mechanics of pairing a single remittance line to the exact open accrual it names — candidate ranking, split allocation, and the partial-clear loop — are worked end to end in matching vendor payments to open accruals.

Drift Detection and Validation

Settlement is trustworthy only if its invariants hold continuously, not just at the moment of application. Drift detection runs control-total and aging checks across the open-accrual and unapplied-cash populations after every batch, and it quarantines anomalies rather than letting them silently accumulate into a misstated close. Each signal carries a deterministic reason code and an action, so the response is driven by policy rather than by whichever analyst noticed first.

Drift signal Detection rule Action
Balance invariant break open_balance ≠ posted − Σ committed applications quarantine accrual, raise BALANCE_DRIFT
Over-application Σ applied_amount on a payment > paid_amount reject batch, raise OVER_APPLIED
Unapplied cash aging on-account credit older than SLA (e.g. 30 days) flag for review, notify vendor manager
Open accrual aging accrual_age_days past the settlement window escalate to short-pay recovery
Duplicate application second write on an existing application_id no-op, log idempotent replay
Currency mismatch payment currency ≠ accrual currency, no rate stamp hold, require explicit conversion

Catching these before the GL journal runs is what preserves margin integrity: a balance-drift accrual caught in reconciliation is a corrected application, while the same corruption discovered after posting is a restated ledger and an audit finding. Aging is the quiet killer here — an unapplied credit or an open accrual that no one revisits is unrecovered cash — so both populations are aged from their origin date and surfaced on every cycle. When drift exceeds a defined tolerance the reconciler triggers an automated exception workflow that routes the affected records to operations with the offending hashes attached, exactly as the upstream mapping layer does for mismatched accruals.

Fallback and Dispute Routing

No production remittance stream reconciles cleanly, so the settlement layer degrades by classifying failures and routing them, never by dropping cash on the floor. Failures fall into three tiers. Short payments — a payment that clears an accrual only partially because the vendor took a deduction — are the most consequential: the cleared portion settles, and the residual gap is handed to deduction & short-pay routing, which decides whether the deduction was authorized and, if not, opens a recovery case. The specific mechanics of recovering an unauthorized short-pay are detailed in routing unauthorized deductions for recovery. Unmatched payments — cash whose remittance references no known open accrual — are parked as on-account and routed to manual review with the resolved vendor and the candidate keys attached, so an analyst adjudicates rather than the system guessing. Over-payments clear the target accrual and leave a positive on-account balance that is itself an aging item, resolved by applying it to a future accrual or returning it.

Every routed exception carries a deterministic code, the offending record hashes, and a suggested remediation lane, and dead-letter retention preserves the original payment and remittance for audit. Critically, routing a short-pay to the deduction workflow does not re-open the cleared portion — the settled cash stays settled and only the disputed delta travels — so a dispute never unwinds a valid application. This is the same graceful-degradation contract the fallback routing logic applies upstream: name the correct lane, emit an audit entry explaining why no clean clearing was possible, and keep the batch moving so the close is never blocked by a single contested payment.

Security and Access Boundaries

Payment and remittance data expose negotiated settlement terms, bank references, and the exact amount every vendor still owes, so the reconciler is governed as a financial control rather than a utility. Incoming payment and remittance files land in an access-restricted zone; raw payloads are encrypted at rest, and field-level encryption protects bank references and monetary elements within persisted records. Role-based access control (RBAC) tags travel with every entity so authorization is enforced per field: ETL developers can deploy reconciler versions and matching configuration but cannot mutate a committed application or a cleared balance; vendor managers can review unapplied cash and adjudicate unmatched payments but cannot edit an applied amount; trade finance analysts can approve short-pay dispositions and export audit trails but cannot alter a payment’s paid_amount. Segregation of duties is explicit — the role that configures tolerance is never the role that clears a disputed balance.

Immutable, hash-chained audit logs capture every ingest, application, reversal, and reprocess event, and because each application is anchored by its application_id and each accrual balance is a pure function of the committed application log, any tampering surfaces as a chain break rather than passing unnoticed — the posture SOX and internal-control review require of a system that clears cash. Reversals are additive, not destructive: correcting a mis-applied payment writes a compensating reversal application and a fresh correct one, so the history of how a balance reached zero survives indefinitely. Treating payment and settlement reconciliation as a versioned, access-governed control is what turns a noisy inflow of wires and remittance files into an audit-ready close: fewer unapplied credits aging into write-offs, short payments recovered instead of absorbed, and a defensible trail from bank credit to cleared accrual.

Frequently Asked Questions

How does the reconciler stop a re-sent remittance from clearing an accrual twice? Two layers. A whole-file duplicate is caught before matching by comparing the content hash of the remittance advice against prior payloads. At the line level, every application carries a deterministic application_id hashed from the payment, the accrual, the remittance reference, and the applied amount, and persistence is an idempotent upsert on that key. Replaying an unchanged remittance produces identical keys, so the second write is a no-op rather than a second clearing.

What happens when a payment is short of the accrual it references? The cash that did arrive is applied and settles that portion of the accrual, and the remaining gap is classified as a short payment. If the gap is within the configured tolerance it clears with a small residual booked to a rounding account; if it exceeds tolerance the accrual stays open for the difference and the delta is routed to the deduction workflow for authorization and possible recovery. The portion that was validly settled is never re-opened by the dispute.

Why is unapplied cash treated as an aging item rather than left on the payment? An over-payment or an unmatched payment leaves cash on-account that has not yet reduced any liability, and cash that no one revisits quietly becomes an unrecovered credit or an eventual write-off. The reconciler ages every on-account balance from its value date and surfaces it on each cycle so it is applied to a future accrual, returned, or adjudicated, rather than forgotten.

How are multi-currency payments reconciled against accruals in another currency? They are never netted by silent conversion. If a payment and the accrual it settles are in different currencies, the reconciler records an explicit, rate-stamped conversion as its own auditable step before applying, so any FX difference is a visible line rather than a rounding artefact. An unstamped currency mismatch is held as a drift signal rather than cleared.

Up one level: Settlement, Deduction & Financial Close