Implementing One-to-Many Batch-to-Invoice Matching

A counterparty rarely pays one invoice at a time. More often a single settlement deposit lands in the bank feed as one lump sum that covers a batch of open invoices settled together — a customer clearing a month of purchase orders in one wire, or a payment processor sweeping a day’s receivables into a single payout. Unlike the one-to-one lookups covered in Exact-Match Hash Comparison, a one-to-many batch-to-invoice match has to search: given a deposit amount, which combination of open invoices, drawn from a bounded candidate pool, sums to that amount within tolerance? This page implements that search end to end inside Multi-Step Reconciliation Chains — candidate generation, a bounded subset-sum search, tie-breaking between equally valid subsets, guardrails against combinatorial blowup, and an audited confirm-and-post step.

One deposit matched to a subset of a blocked invoice candidate pool A deposit box on the left, valued at $4,820.00, feeds an arrow into a dashed candidate pool boundary labelled candidate pool: counterparty plus date window. Inside the pool are four invoice boxes stacked vertically: INV-101 at $1,200.00, INV-102 at $1,620.00, and INV-103 at $2,000.00 are filled in the matched color and each send a converging arrow to a MATCH POSTED box on the right showing a sum of $4,820.00 and a match_decision of SUBSET_SUM_OK. The fourth invoice, INV-104 at $940.00, is filled in a neutral color and connects with a dashed arrow to a HELD box labelled next cycle, not in optimal subset, since it is not part of the subset that sums to the deposit. DEPOSIT $4,820.00 trace_id 8f2c9a candidate pool counterparty + date window INV-101 $1,200.00 INV-102 $1,620.00 INV-103 $2,000.00 INV-104 $940.00 Σ matched = deposit ± tolerance MATCH POSTED Σ = $4,820.00 match_decision: SUBSET_SUM_OK source_hash logged HELD — next cycle not in optimal subset

Prerequisites

Step 1 — Model the deposit and the invoice pool

Both records are frozen and typed. amount is always Decimal, quantized to two decimal places at the validation boundary, because subset-sum arithmetic that mixes float rounding error with tolerance comparisons produces false negatives on penny-level totals.

python
from pydantic import BaseModel, ConfigDict, field_validator
from decimal import Decimal
from datetime import date
from uuid import UUID

class Deposit(BaseModel):
    model_config = ConfigDict(frozen=True)

    deposit_id: UUID
    counterparty_id: str
    amount: Decimal
    value_date: date
    source_hash: str

    @field_validator("amount")
    @classmethod
    def positive_two_dp(cls, v: Decimal) -> Decimal:
        if v <= 0:
            raise ValueError("deposit amount must be positive")
        return v.quantize(Decimal("0.01"))

class OpenInvoice(BaseModel):
    model_config = ConfigDict(frozen=True)

    invoice_id: str
    counterparty_id: str
    amount: Decimal
    due_date: date

    @field_validator("amount")
    @classmethod
    def positive_two_dp(cls, v: Decimal) -> Decimal:
        return v.quantize(Decimal("0.01"))

Step 2 — Generate candidates by blocking, not brute force

Never run the aggregation search against the entire open-invoice ledger. Block first: restrict the pool to the same counterparty_id and a due_date inside a bounded window around the deposit’s value_date. Blocking is what keeps the search space small enough for the next step to be tractable — the same discipline that governs one-to-one comparisons in Date-Window & Amount Tolerance Rules applies here, except the payoff is larger, since every unnecessary candidate multiplies the size of the subset search rather than just the number of comparisons.

python
from datetime import timedelta

def generate_candidates(
    deposit: Deposit,
    open_invoices: list[OpenInvoice],
    date_window_days: int = 10,
    max_candidates: int = 40,
) -> list[OpenInvoice]:
    lo = deposit.value_date - timedelta(days=date_window_days)
    hi = deposit.value_date + timedelta(days=date_window_days)
    pool = [
        inv for inv in open_invoices
        if inv.counterparty_id == deposit.counterparty_id
        and lo <= inv.due_date <= hi
        and inv.amount <= deposit.amount  # an invoice larger than the deposit can never be in a valid subset
    ]
    pool.sort(key=lambda inv: inv.amount, reverse=True)
    return pool[:max_candidates]

Sorting candidates by amount descending before the search starts does double duty: it feeds a greedy pass that resolves the common case in a single linear pass, and it gives the fallback search a branch order that finds valid subsets faster, since large invoices eliminate more of the remaining deposit per step.

Step 3 — Try greedy aggregation first

Most batches are clean: the counterparty paid exactly the invoices they intended to, with no partial payments folded in. A greedy pass — repeatedly take the largest remaining candidate that does not overshoot the deposit — resolves this case in O(n log n) without ever touching the combinatorial search. Only fall through to Step 4 when greedy fails to land inside tolerance.

python
def greedy_subset(
    deposit_amount: Decimal,
    candidates: list[OpenInvoice],
    amount_tolerance: Decimal,
) -> list[OpenInvoice] | None:
    remaining = deposit_amount
    chosen: list[OpenInvoice] = []
    for inv in candidates:  # already sorted descending by amount
        if inv.amount <= remaining + amount_tolerance:
            chosen.append(inv)
            remaining -= inv.amount
        if abs(remaining) <= amount_tolerance:
            return chosen
    return None

Step 4 — Bounded subset-sum search when greedy fails

When greedy overshoots or undershoots, fall back to a depth-first search over the remaining candidates — but bound it hard. Unconstrained subset-sum over n candidates is exponential; a pool of 40 invoices has over a trillion subsets. Two guardrails keep this safe: max_subset_size caps how many invoices a single deposit can be split across, and a hard max_candidates counter aborts the search once it has explored too many branches without finding a solution, rather than letting it run to exhaustion.

python
class CombinatorialBlowupError(Exception):
    pass

def bounded_subset_sum(
    deposit_amount: Decimal,
    candidates: list[OpenInvoice],
    amount_tolerance: Decimal,
    max_subset_size: int = 6,
    max_nodes: int = 20_000,
) -> list[list[OpenInvoice]]:
    """Depth-first search bounded by subset size and node count.
    Returns every subset that lands inside tolerance, so the caller
    can detect ties rather than silently keeping the first hit.
    """
    solutions: list[list[OpenInvoice]] = []
    nodes_visited = 0

    def dfs(start: int, remaining: Decimal, chosen: list[OpenInvoice]) -> None:
        nonlocal nodes_visited
        nodes_visited += 1
        if nodes_visited > max_nodes:
            raise CombinatorialBlowupError(
                f"exceeded {max_nodes} search nodes with {len(candidates)} candidates"
            )
        if abs(remaining) <= amount_tolerance and chosen:
            solutions.append(list(chosen))
        if len(chosen) >= max_subset_size or remaining < -amount_tolerance:
            return
        for i in range(start, len(candidates)):
            inv = candidates[i]
            if inv.amount > remaining + amount_tolerance:
                continue  # candidates are sorted descending; further ones only get smaller
            chosen.append(inv)
            dfs(i + 1, remaining - inv.amount, chosen)
            chosen.pop()

    dfs(0, deposit_amount, [])
    return solutions

Pruning on inv.amount > remaining + amount_tolerance is what makes the bound effective in practice rather than just in theory: because candidates are sorted descending, the moment one candidate is too large for the remaining balance, every candidate after it is too, so the loop can be tightened further with an early break in high-volume deployments. The max_nodes counter is the real safety valve — it guarantees the function returns (or raises) in bounded time regardless of pool composition.

Step 5 — Break ties between multiple valid subsets

A bounded search can legitimately return more than one subset that sums within tolerance — two different combinations of invoices that both land on the deposit amount. Resolving this deterministically matters for audit reproducibility: the same inputs must always produce the same match. Rank candidates by fewest invoices first (a cleaner ledger entry), then by smallest residual variance, then by earliest due_date as a final deterministic tiebreaker.

python
def select_best_subset(
    solutions: list[list[OpenInvoice]],
    deposit_amount: Decimal,
) -> tuple[list[OpenInvoice], bool]:
    def variance(subset: list[OpenInvoice]) -> Decimal:
        return abs(deposit_amount - sum((inv.amount for inv in subset), Decimal("0")))

    ranked = sorted(
        solutions,
        key=lambda s: (len(s), variance(s), min(inv.due_date for inv in s)),
    )
    is_ambiguous = len(solutions) > 1 and len(ranked[0]) == len(ranked[1])
    return ranked[0], is_ambiguous

is_ambiguous does not block the match — it is a signal, not a veto. A tier-appropriate policy might auto-post unambiguous single-solution matches and route ambiguous ones to a manual review queue for a human to confirm which invoices the counterparty actually intended to settle.

Step 6 — Confirm and post with a structured audit record

The final step commits the chosen subset, marks each invoice SETTLED, and emits one audit log line per match carrying the trace_id, source_hash, and match_decision triple required for SOX evidence trails.

python
import hashlib
import logging
from datetime import datetime, timezone

log = logging.getLogger("matching.batch_to_invoice")

def confirm_and_post(
    deposit: Deposit,
    subset: list[OpenInvoice],
    is_ambiguous: bool,
) -> str:
    total = sum((inv.amount for inv in subset), Decimal("0"))
    decision = "AMBIGUOUS_SUBSET" if is_ambiguous else "SUBSET_SUM_OK"
    blob = f"{deposit.deposit_id}|{','.join(sorted(i.invoice_id for i in subset))}|{total}"
    audit_hash = hashlib.sha256(blob.encode("utf-8")).hexdigest()

    log.info(
        "batch_to_invoice.matched",
        extra={
            "trace_id": str(deposit.deposit_id),
            "source_hash": deposit.source_hash,
            "match_decision": decision,
            "invoice_ids": [inv.invoice_id for inv in subset],
            "invoice_count": len(subset),
            "deposit_amount": str(deposit.amount),
            "matched_total": str(total),
            "audit_hash": audit_hash,
            "posted_at": datetime.now(timezone.utc).isoformat(),
        },
    )
    return decision

Posting only after the audit line is constructed — not after — means every ledger write has a corresponding, hash-chained log entry it can be reconciled against later, consistent with NIST SP 800-53 Rev. 5 AU-2 audit-event guidance.

Configuration boundary table

Parameter Default Valid range Notes
amount_tolerance Decimal("0.02") 0.005.00 Absolute residual allowed between deposit and Σ invoices
date_window_days 10 145 Blocking window around value_date before search runs
max_subset_size 6 212 Hard cap on invoices per matched deposit
max_candidates 40 5100 Pool size after blocking, before the search starts
greedy_first true boolean Attempt the O(n log n) greedy pass before the bounded DFS
max_nodes 20000 1000200000 DFS node budget before COMBINATORIAL_BLOWUP is raised

Raising max_subset_size or max_candidates grows the search space multiplicatively, not linearly — tune max_nodes alongside either change and re-run the fixture in the next section before deploying.

Verification and testing

A deterministic fixture where the deposit exactly equals the sum of a known invoice subset is the baseline test: it must resolve through the greedy path without ever invoking the bounded search.

python
def test_greedy_resolves_clean_batch():
    deposit = Deposit(
        deposit_id=UUID(int=1),
        counterparty_id="MEGACORP",
        amount=Decimal("4820.00"),
        value_date=date(2026, 6, 1),
        source_hash="d34db33f",
    )
    invoices = [
        OpenInvoice(invoice_id="INV-101", counterparty_id="MEGACORP",
                     amount=Decimal("1200.00"), due_date=date(2026, 6, 3)),
        OpenInvoice(invoice_id="INV-102", counterparty_id="MEGACORP",
                     amount=Decimal("1620.00"), due_date=date(2026, 6, 4)),
        OpenInvoice(invoice_id="INV-103", counterparty_id="MEGACORP",
                     amount=Decimal("2000.00"), due_date=date(2026, 6, 5)),
        OpenInvoice(invoice_id="INV-104", counterparty_id="MEGACORP",
                     amount=Decimal("940.00"), due_date=date(2026, 6, 6)),
    ]
    pool = generate_candidates(deposit, invoices)
    subset = greedy_subset(deposit.amount, pool, Decimal("0.02"))
    assert subset is not None
    assert sum(i.amount for i in subset) == Decimal("4820.00")
    assert {i.invoice_id for i in subset} == {"INV-101", "INV-102", "INV-103"}

    decision = confirm_and_post(deposit, subset, is_ambiguous=False)
    assert decision == "SUBSET_SUM_OK"

A second fixture should force the fallback path — remove one invoice from the clean set so greedy overshoots and only the bounded DFS finds the two-invoice subset that lands within tolerance — and a third should set max_nodes deliberately low against a wide, unhelpful candidate pool to assert that CombinatorialBlowupError is raised rather than the process hanging.

Troubleshooting

  • NO_SUBSET_FOUND — the bounded search returns an empty list. Root cause: the true matching invoices fall outside the current date_window_days, or amount_tolerance is tighter than a legitimate rounding difference (partial credit note, bank fee netted out of the wire). Fix: widen the blocking window incrementally and re-run before assuming the deposit needs manual research; log the miss with the pool size and total pool amount for diagnosis.
  • AMBIGUOUS_SUBSET — more than one subset sums within tolerance. Root cause: the candidate pool contains invoices whose amounts are combinatorially interchangeable (e.g. two pairs that both total the deposit). Fix: this is not an error to suppress — route it to manual review per Step 5 rather than auto-posting the first solution found, since silently picking one risks misattributing which invoices were actually settled.
  • COMBINATORIAL_BLOWUPCombinatorialBlowupError raised mid-search. Root cause: blocking let too many same-amount or near-amount invoices into the pool, so the DFS branches faster than max_nodes allows. Fix: tighten date_window_days, lower max_candidates, or reduce max_subset_size — the search space shrinks combinatorially with any of the three, so a small reduction in pool size buys a large reduction in worst-case nodes.
  • TOLERANCE_EXCEEDED — greedy and the bounded search both terminate with a nonzero residual larger than amount_tolerance. Root cause: the deposit genuinely does not equal the sum of any subset of the blocked pool — it may include invoices from a different counterparty alias, or a short payment. Fix: do not silently widen amount_tolerance to force a match; instead surface the residual amount alongside the closest candidate subset so a reviewer can decide whether it is a short payment requiring a write-off code.
  • STALE_INVOICE_STATE — a chosen invoice was already settled by a concurrent match before posting. Root cause: two deposits raced against overlapping candidate pools without a row lock. Fix: acquire a row-level lock (e.g. SELECT ... FOR UPDATE SKIP LOCKED) on the invoice rows between Step 5 and Step 6, and re-validate that every invoice in the chosen subset is still OPEN immediately before posting.

Part of Multi-Step Reconciliation Chains within Transaction Matching Algorithms & Logic.