Skip to content
Learn Agentic AI13 min read0 views

AI Agent for Invoice Reconciliation: Matching Payments to Invoices Automatically

Build an AI agent that automatically matches incoming payments to outstanding invoices using fuzzy matching, handles exceptions, and generates reconciliation reports.

The Invoice Reconciliation Challenge

Accounts receivable teams spend hours matching incoming bank payments to outstanding invoices. The difficulty is that payment references are often incomplete, amounts may include partial payments or combine multiple invoices, and customer names on bank statements do not always match the billing system. An AI agent can automate the straightforward matches and surface only the genuinely ambiguous cases for human review.

Agent Components

  1. Data Loader — load invoices and bank transactions
  2. Matching Engine — multi-strategy matching algorithm
  3. Exception Handler — manage unmatched or ambiguous items
  4. Report Generator — produce reconciliation reports

Step 1: Data Models

Define structured models for invoices and payments.

from pydantic import BaseModel
from datetime import date
from enum import Enum


class MatchConfidence(str, Enum):
    EXACT = "exact"
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"
    UNMATCHED = "unmatched"


class Invoice(BaseModel):
    invoice_id: str
    customer_name: str
    customer_id: str
    amount: float
    currency: str
    due_date: date
    status: str  # "open", "partial", "paid"
    remaining_balance: float


class BankTransaction(BaseModel):
    transaction_id: str
    date: date
    amount: float
    currency: str
    reference: str  # Bank reference / memo
    counterparty: str


class MatchResult(BaseModel):
    transaction_id: str
    invoice_id: str | None
    confidence: MatchConfidence
    match_reason: str
    amount_difference: float

Step 2: Multi-Strategy Matching Engine

The matching engine tries several strategies in order of confidence — exact reference match, amount match, and fuzzy matching.

from difflib import SequenceMatcher


class ReconciliationEngine:
    def __init__(
        self,
        invoices: list[Invoice],
        transactions: list[BankTransaction],
    ):
        self.invoices = {inv.invoice_id: inv for inv in invoices}
        self.open_invoices = [
            inv for inv in invoices if inv.status != "paid"
        ]
        self.transactions = transactions
        self.results: list[MatchResult] = []

    def reconcile(self) -> list[MatchResult]:
        """Run all matching strategies."""
        unmatched_txns = list(self.transactions)

        # Strategy 1: Exact reference match
        unmatched_txns = self._match_by_reference(unmatched_txns)

        # Strategy 2: Exact amount + customer name match
        unmatched_txns = self._match_by_amount_and_name(unmatched_txns)

        # Strategy 3: Fuzzy matching for remaining
        unmatched_txns = self._fuzzy_match(unmatched_txns)

        # Mark remaining as unmatched
        for txn in unmatched_txns:
            self.results.append(
                MatchResult(
                    transaction_id=txn.transaction_id,
                    invoice_id=None,
                    confidence=MatchConfidence.UNMATCHED,
                    match_reason="No matching invoice found",
                    amount_difference=txn.amount,
                )
            )

        return self.results

    def _match_by_reference(
        self, transactions: list[BankTransaction]
    ) -> list[BankTransaction]:
        """Match by invoice number in bank reference."""
        unmatched = []
        for txn in transactions:
            matched = False
            for inv in self.open_invoices:
                if inv.invoice_id.lower() in txn.reference.lower():
                    diff = abs(txn.amount - inv.remaining_balance)
                    self.results.append(
                        MatchResult(
                            transaction_id=txn.transaction_id,
                            invoice_id=inv.invoice_id,
                            confidence=MatchConfidence.EXACT,
                            match_reason=(
                                f"Invoice ID found in reference"
                            ),
                            amount_difference=diff,
                        )
                    )
                    matched = True
                    break
            if not matched:
                unmatched.append(txn)
        return unmatched

    def _match_by_amount_and_name(
        self, transactions: list[BankTransaction]
    ) -> list[BankTransaction]:
        """Match by exact amount and similar customer name."""
        unmatched = []
        for txn in transactions:
            candidates = [
                inv for inv in self.open_invoices
                if abs(inv.remaining_balance - txn.amount) < 0.01
            ]

            best_match = None
            best_score = 0.0

            for inv in candidates:
                similarity = SequenceMatcher(
                    None,
                    txn.counterparty.lower(),
                    inv.customer_name.lower(),
                ).ratio()
                if similarity > best_score:
                    best_score = similarity
                    best_match = inv

            if best_match and best_score > 0.6:
                self.results.append(
                    MatchResult(
                        transaction_id=txn.transaction_id,
                        invoice_id=best_match.invoice_id,
                        confidence=MatchConfidence.HIGH,
                        match_reason=(
                            f"Amount match + name similarity "
                            f"({best_score:.0%})"
                        ),
                        amount_difference=0.0,
                    )
                )
            else:
                unmatched.append(txn)

        return unmatched

    def _fuzzy_match(
        self, transactions: list[BankTransaction]
    ) -> list[BankTransaction]:
        """Fuzzy match using amount proximity and name similarity."""
        unmatched = []
        tolerance = 0.05  # 5% amount tolerance

        for txn in transactions:
            best_match = None
            best_score = 0.0

            for inv in self.open_invoices:
                amount_diff = abs(
                    txn.amount - inv.remaining_balance
                )
                amount_ratio = (
                    amount_diff / inv.remaining_balance
                    if inv.remaining_balance > 0
                    else 1.0
                )
                if amount_ratio > tolerance:
                    continue

                name_sim = SequenceMatcher(
                    None,
                    txn.counterparty.lower(),
                    inv.customer_name.lower(),
                ).ratio()

                combined_score = (
                    name_sim * 0.6 + (1 - amount_ratio) * 0.4
                )

                if combined_score > best_score:
                    best_score = combined_score
                    best_match = inv

            if best_match and best_score > 0.5:
                self.results.append(
                    MatchResult(
                        transaction_id=txn.transaction_id,
                        invoice_id=best_match.invoice_id,
                        confidence=MatchConfidence.MEDIUM,
                        match_reason=(
                            f"Fuzzy match (score: {best_score:.2f})"
                        ),
                        amount_difference=abs(
                            txn.amount - best_match.remaining_balance
                        ),
                    )
                )
            else:
                unmatched.append(txn)

        return unmatched

Step 3: LLM-Powered Exception Resolution

For items the rule-based engine cannot match, the LLM analyzes context clues.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

from openai import OpenAI

client = OpenAI()


class LLMMatchSuggestion(BaseModel):
    suggested_invoice_id: str | None
    reasoning: str
    confidence: str


def resolve_exception(
    txn: BankTransaction, open_invoices: list[Invoice]
) -> LLMMatchSuggestion:
    """Use LLM to resolve an unmatched transaction."""
    invoices_text = "\n".join(
        f"- {inv.invoice_id}: {inv.customer_name}, "
        f"${inv.remaining_balance:.2f}, due {inv.due_date}"
        for inv in open_invoices
    )

    response = client.beta.chat.completions.parse(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are an accounts receivable specialist. "
                    "Match the bank transaction to the most likely "
                    "invoice based on amount, name, date, and reference."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"Transaction: ${txn.amount:.2f} from "
                    f"'{txn.counterparty}' ref: '{txn.reference}' "
                    f"on {txn.date}\n\nOpen Invoices:\n"
                    f"{invoices_text}"
                ),
            },
        ],
        response_format=LLMMatchSuggestion,
    )
    return response.choices[0].message.parsed

Step 4: Reconciliation Report

def generate_report(results: list[MatchResult]) -> dict:
    """Generate reconciliation summary report."""
    total = len(results)
    by_confidence = {}
    for r in results:
        by_confidence.setdefault(r.confidence, []).append(r)

    return {
        "total_transactions": total,
        "exact_matches": len(by_confidence.get(MatchConfidence.EXACT, [])),
        "high_confidence": len(by_confidence.get(MatchConfidence.HIGH, [])),
        "medium_confidence": len(by_confidence.get(MatchConfidence.MEDIUM, [])),
        "unmatched": len(by_confidence.get(MatchConfidence.UNMATCHED, [])),
        "auto_match_rate": (
            (total - len(by_confidence.get(MatchConfidence.UNMATCHED, [])))
            / total * 100
            if total > 0
            else 0
        ),
    }

FAQ

How do you handle partial payments where a customer pays less than the invoice amount?

Track a remaining_balance field on each invoice. When a partial match is detected (payment amount is less than invoice amount), record the partial payment and update the remaining balance. The agent flags these for review and suggests whether to apply as partial payment or investigate further.

What happens when one payment covers multiple invoices?

The agent should detect lump-sum payments by checking if the payment amount matches the sum of multiple open invoices from the same customer. Implement a combination search that tries subsets of open invoices to find matching totals, starting with the most likely groupings.

How do you handle foreign currency payments?

Include a currency conversion step using daily exchange rates. Match the converted amount rather than the raw amount, and store the exchange rate used for audit purposes. Flag any matches where the exchange rate assumption could change the outcome.


#InvoiceReconciliation #PaymentMatching #Accounting #FuzzyMatching #Automation #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.