Skip to content
Learn Agentic AI
Learn Agentic AI13 min read1 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.

flowchart TD
    START["AI Agent for Invoice Reconciliation: Matching Pay…"] --> A
    A["The Invoice Reconciliation Challenge"]
    A --> B
    B["Agent Components"]
    B --> C
    C["Step 1: Data Models"]
    C --> D
    D["Step 2: Multi-Strategy Matching Engine"]
    D --> E
    E["Step 3: LLM-Powered Exception Resolution"]
    E --> F
    F["Step 4: Reconciliation Report"]
    F --> G
    G["FAQ"]
    G --> DONE["Key Takeaways"]
    style START fill:#4f46e5,stroke:#4338ca,color:#fff
    style DONE fill:#059669,stroke:#047857,color:#fff
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.

flowchart LR
    S0["Step 1: Data Models"]
    S0 --> S1
    S1["Step 2: Multi-Strategy Matching Engine"]
    S1 --> S2
    S2["Step 3: LLM-Powered Exception Resolution"]
    S2 --> S3
    S3["Step 4: Reconciliation Report"]
    style S0 fill:#4f46e5,stroke:#4338ca,color:#fff
    style S3 fill:#059669,stroke:#047857,color:#fff
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
C

Written by

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.

Related Articles You May Like

Healthcare

AI Voice Agents for Prior Authorization: Automating the Payer Phone Call Hellscape

A technical playbook for deploying AI voice agents that place prior authorization calls to payer IVRs, navigate hold queues, and capture auth numbers autonomously.

Voice AI Agents

AI Voice Agent Appointment Booking Automation Guide

Learn how AI voice agents automate appointment booking, reduce no-shows by up to 35%, and free staff for higher-value work across industries.

Use Cases

Automating Client Document Collection: How AI Agents Chase Missing Tax Documents and Reduce Filing Delays

See how AI agents automate tax document collection — chasing missing W-2s, 1099s, and receipts via calls and texts to eliminate the #1 CPA bottleneck.

Use Cases

Year-Round Client Engagement for CPA Firms Using AI Chat and Voice Agents

Learn how CPA firms use AI chat and voice agents for year-round client engagement — quarterly check-ins, tax planning reminders, and estimated payment alerts.

Learn Agentic AI

AI Agents for IT Helpdesk: L1 Automation, Ticket Routing, and Knowledge Base Integration

Build IT helpdesk AI agents with multi-agent architecture for triage, device, network, and security issues. RAG-powered knowledge base, automated ticket creation, routing, and escalation.

Learn Agentic AI

Creating an AI Email Assistant Agent: Triage, Draft, and Schedule with Gmail API

Build an AI email assistant that reads your inbox, classifies urgency, drafts context-aware responses, and schedules sends using OpenAI Agents SDK and Gmail API.