AI Agent for Accounts Payable: Automating Invoice Processing and PO Matching
Accounts payable is the textbook case for AI automation: it's high-volume, rule-based, document-heavy, and the consequences of errors are clear and measurable. The average AP team processes hundreds to thousands of invoices per month, spending 15–20 minutes per invoice on a workflow that follows the same logic every time.
An AP automation agent handles the full cycle — from invoice receipt to ERP posting — touching humans only for exceptions that genuinely require judgment.
The Manual AP Workflow
A typical invoice processing cycle:
- Invoice arrives (email PDF, EDI, supplier portal)
- AP clerk extracts header and line-item data manually
- Clerk looks up the matching Purchase Order in the ERP
- 3-way match: Invoice ↔ PO ↔ Goods Receipt
- If matched: route for approval based on dollar threshold
- If exceptions: route to the right person (wrong price, quantity mismatch, no PO)
- Approved invoices posted to the ERP (NetSuite, SAP, Coupa)
- Payment scheduled according to vendor terms
Steps 2–7 are fully automatable for the 80% of invoices that follow standard patterns. The agent handles those. Step 6 (exceptions) and payment authorization stay with humans.
Architecture
┌──────────────────────────────────────────────────────────────────┐
│ AP AUTOMATION AGENT │
│ │
│ Trigger: invoice arrives (email / S3 drop / EDI) │
│ │
│ 1. Extract invoice data (OCR + LLM) │
│ 2. Look up PO in ERP MCP │
│ 3. 3-way match: Invoice ↔ PO ↔ GR │
│ 4. Classify: MATCHED | EXCEPTION │
│ 5a. MATCHED: post to ERP, schedule payment │
│ 5b. EXCEPTION: route to correct approver with context │
└────────────────────────────────────────────────────────────────┘
│ │ │
┌─────▼────┐ ┌──────▼──────┐ ┌────▼───────────┐
│ File MCP │ │ ERP MCP │ │ Notification │
│(invoices)│ │ (NetSuite / │ │ MCP │
│ │ │ Coupa) │ │ (Slack/email) │
└──────────┘ └─────────────┘ └────────────────┘
MCP Configuration
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data/invoices/inbox"]
},
"erp": {
"command": "uvx",
"args": ["mcp-server-netsuite"],
"env": {
"NS_ACCOUNT_ID": "${NS_ACCOUNT_ID}",
"NS_CONSUMER_KEY": "${NS_CONSUMER_KEY}",
"NS_TOKEN_ID": "${NS_TOKEN_ID}"
}
},
"notifications": {
"command": "uvx",
"args": ["mcp-server-slack"],
"env": {
"SLACK_BOT_TOKEN": "${SLACK_TOKEN}"
}
}
}
}
Step 1: Invoice Data Extraction
PDF invoices require OCR + structured extraction. The LLM handles the extraction from raw OCR text:
import anthropic
import json
import pytesseract
from PIL import Image
import pdf2image
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = {
"vendor_name": "string",
"vendor_tax_id": "string | null",
"invoice_number": "string",
"invoice_date": "ISO date string",
"due_date": "ISO date string | null",
"currency": "3-letter ISO code",
"subtotal": "float",
"tax_amount": "float",
"total_amount": "float",
"po_number": "string | null — purchase order reference if present",
"line_items": [
{
"description": "string",
"quantity": "float",
"unit_price": "float",
"line_total": "float",
"gl_account_hint": "string | null"
}
],
"payment_terms": "string | null — e.g. 'Net 30', '2/10 Net 30'",
"bank_details": {
"account_number": "string | null",
"routing_number": "string | null",
"bank_name": "string | null"
}
}
def extract_invoice(pdf_path: str) -> dict:
# Convert PDF to images and OCR
images = pdf2image.convert_from_path(pdf_path, dpi=300)
raw_text = "\n\n--- PAGE BREAK ---\n\n".join(
pytesseract.image_to_string(img) for img in images
)
response = client.messages.create(
model="claude-sonnet-4-6", # needs good reasoning for messy OCR
max_tokens=2048,
messages=[{
"role": "user",
"content": f"""Extract structured invoice data from this OCR text.
Return ONLY valid JSON matching the schema. Use null for missing fields.
Do not infer or guess values not present in the text.
Schema: {json.dumps(EXTRACTION_SCHEMA, indent=2)}
OCR text:
{raw_text}"""
}]
)
return json.loads(response.content[0].text)
Step 2: 3-Way Matching Logic
from dataclasses import dataclass
from enum import Enum
class MatchStatus(Enum):
MATCHED = "matched"
PRICE_VARIANCE = "price_variance"
QUANTITY_VARIANCE = "quantity_variance"
NO_PO_FOUND = "no_po_found"
NO_GR_FOUND = "no_gr_found"
DUPLICATE = "duplicate"
PRICE_TOLERANCE = 0.02 # 2% price variance allowed
QUANTITY_TOLERANCE = 0.01 # 1% quantity variance allowed
@dataclass
class MatchResult:
status: MatchStatus
po_number: str | None
invoice_total: float
po_total: float | None
gr_total: float | None
variances: list[dict]
exception_reason: str | None
def three_way_match(invoice: dict, erp_client) -> MatchResult:
# Check for duplicate
existing = erp_client.find_invoice(
vendor=invoice["vendor_name"],
invoice_number=invoice["invoice_number"]
)
if existing:
return MatchResult(
status=MatchStatus.DUPLICATE,
po_number=None,
invoice_total=invoice["total_amount"],
po_total=None, gr_total=None, variances=[],
exception_reason=f"Duplicate of {existing['id']}"
)
# Find PO
po = erp_client.get_purchase_order(invoice.get("po_number"))
if not po:
return MatchResult(
status=MatchStatus.NO_PO_FOUND,
po_number=invoice.get("po_number"),
invoice_total=invoice["total_amount"],
po_total=None, gr_total=None, variances=[],
exception_reason="No matching PO found"
)
# Find Goods Receipt
gr = erp_client.get_goods_receipt(po["id"])
if not gr:
return MatchResult(
status=MatchStatus.NO_GR_FOUND,
po_number=po["number"],
invoice_total=invoice["total_amount"],
po_total=po["total"],
gr_total=None, variances=[],
exception_reason="No goods receipt found for PO"
)
# Check line-item variances
variances = []
for inv_line in invoice["line_items"]:
po_line = find_matching_po_line(inv_line, po["lines"])
if not po_line:
continue
price_diff = abs(inv_line["unit_price"] - po_line["unit_price"]) / po_line["unit_price"]
qty_diff = abs(inv_line["quantity"] - po_line["quantity"]) / po_line["quantity"]
if price_diff > PRICE_TOLERANCE:
variances.append({
"type": "price",
"description": inv_line["description"],
"invoice_price": inv_line["unit_price"],
"po_price": po_line["unit_price"],
"variance_pct": round(price_diff * 100, 2)
})
if qty_diff > QUANTITY_TOLERANCE:
variances.append({
"type": "quantity",
"description": inv_line["description"],
"invoice_qty": inv_line["quantity"],
"po_qty": po_line["quantity"],
"variance_pct": round(qty_diff * 100, 2)
})
if variances:
status = MatchStatus.PRICE_VARIANCE if variances[0]["type"] == "price" \
else MatchStatus.QUANTITY_VARIANCE
return MatchResult(
status=status,
po_number=po["number"],
invoice_total=invoice["total_amount"],
po_total=po["total"],
gr_total=gr["total"],
variances=variances,
exception_reason=f"{len(variances)} line-item variance(s)"
)
return MatchResult(
status=MatchStatus.MATCHED,
po_number=po["number"],
invoice_total=invoice["total_amount"],
po_total=po["total"],
gr_total=gr["total"],
variances=[],
exception_reason=None
)
Step 3: Auto-Post or Route Exception
APPROVAL_THRESHOLDS = {
"auto_approve": 5_000, # under $5K: auto-post after match
"manager_approval": 25_000, # $5K–$25K: manager
"director_approval": 100_000,# $25K–$100K: director
"cfo_approval": float('inf') # over $100K: CFO
}
def process_match_result(invoice: dict, match: MatchResult, erp_client, notifier):
if match.status == MatchStatus.MATCHED:
amount = invoice["total_amount"]
# Determine approval requirement
if amount < APPROVAL_THRESHOLDS["auto_approve"]:
erp_client.post_invoice(invoice, match.po_number)
erp_client.schedule_payment(invoice, terms=invoice["payment_terms"])
else:
approver = get_required_approver(amount)
notifier.send_approval_request(
approver=approver,
invoice=invoice,
match_summary=match,
approve_url=f"{ERP_URL}/approve/{invoice['invoice_number']}"
)
else:
# Route exception to AP team with full context
notifier.send_exception_alert(
channel="#ap-exceptions",
message=build_exception_message(invoice, match)
)
def build_exception_message(invoice: dict, match: MatchResult) -> str:
return f"""⚠️ AP Exception — {match.status.value}
**Vendor:** {invoice['vendor_name']}
**Invoice:** {invoice['invoice_number']} | **Amount:** ${invoice['total_amount']:,.2f}
**PO:** {match.po_number or 'Not found'}
**Reason:** {match.exception_reason}
{f"**Variances:**" + chr(10) + chr(10).join(
f"• {v['description']}: invoice {v.get('invoice_price') or v.get('invoice_qty')} "
f"vs PO {v.get('po_price') or v.get('po_qty')} ({v['variance_pct']}%)"
for v in match.variances
) if match.variances else ""}
Review invoice: {ERP_URL}/invoices/{invoice['invoice_number']}"""
Results: What AP Teams Get Back
For a team processing 500 invoices/month:
| Metric | Before | After |
|---|---|---|
| Time per matched invoice | 15–20 min | 0 min (fully automated) |
| Time per exception | 30–45 min | 10 min (context pre-built) |
| Duplicate invoices caught | Manually, often missed | 100% automated |
| Invoice processing lag | 2–5 days | 2–4 hours |
| AP headcount required | Based on volume | Focus on exceptions only |
The AP team stops being a data entry function and becomes an exception management team. Matched invoices — typically 75–85% of volume — flow through automatically. The team focuses entirely on the 15–25% that needs judgment.
Book a strategy session to automate your accounts payable workflow.