Skip to content
Learn Agentic AI
Learn Agentic AI13 min read3 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 LR
    INPUT(["User intent"])
    PARSE["Parse plus<br/>classify"]
    PLAN["Plan and tool<br/>selection"]
    AGENT["Agent loop<br/>LLM plus tools"]
    GUARD{"Guardrails<br/>and policy"}
    EXEC["Execute and<br/>verify result"]
    OBS[("Trace and metrics")]
    OUT(["Outcome plus<br/>next action"])
    INPUT --> PARSE --> PLAN --> AGENT --> GUARD
    GUARD -->|Pass| EXEC --> OUT
    GUARD -->|Fail| AGENT
    AGENT --> OBS
    style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
    style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
    style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
    style OUT 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.

Hear it before you finish reading

Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.

Try Live Demo →
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.

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.

Still reading? Stop comparing — try CallSphere live.

CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.

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

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.

Agentic AI

mcp-linear in 2026: Agentic Ticket Triage and the End of Manual Issue Tracking

Linear added native MCP agent support April 23, 2026. We cover the triage automation pattern, search filters, and how a code-aware agent triages issues with linked PRs and priority suggestions.

Agentic AI

mcp-airtable in 2026: 24-Action Ops Agents on Top of Your Bases

Airtable shipped its official MCP server on Feb 11, 2026. We cover the action surface, the StackOne 24-action build, and the ops-agent loop for inventory, campaigns, and task triage.