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
- Data Loader — load invoices and bank transactions
- Matching Engine — multi-strategy matching algorithm
- Exception Handler — manage unmatched or ambiguous items
- 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
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.