Education· Last updated April 6, 2026

How to Automate Purchase Order to Invoice Matching with FinAudit AI API

Build an automated PO-to-invoice matching pipeline using FinAudit AI API. Catch quantity discrepancies, price overrides, and unapproved charges before payment.

How to Automate Purchase Order to Invoice Matching with FinAudit AI API

PO-to-invoice matching is one of the most common bottlenecks in accounts payable. Finance teams manually compare purchase order line items against vendor invoices, hunting for quantity mismatches, unit price overrides, and charges for items that were never ordered. In organizations processing thousands of invoices per month, this becomes a full-time job — or an expensive source of overpayment.

The fix is three-way matching automation: compare the purchase order, the goods receipt, and the vendor invoice in a single pipeline that flags exceptions automatically.

This guide shows you how to build that pipeline using the FinAudit AI API in Python.

The Three-Way Match Logic

Traditional two-way matching compares PO ↔ Invoice. Three-way matching adds the receiving report (goods receipt notice, GRN):

Purchase Order (PO)    ← what was approved
       ↓
Goods Receipt (GRN)    ← what was actually delivered
       ↓
Vendor Invoice         ← what the vendor is charging

All three documents must agree on quantity and price within acceptable tolerances. FinAudit AI extracts structured data from all three document types and performs the comparison programmatically.

Step 1: Setup

pip install requests python-dotenv
import os
import json
import requests
from pathlib import Path
from dotenv import load_dotenv
 
load_dotenv()
 
FINAUDIT_KEY = os.getenv("YOUR_API_KEY")
FINAUDIT_BASE = "https://apivult.com/api/finaudit"
 
HEADERS = {
    "X-RapidAPI-Key": FINAUDIT_KEY,
    "Content-Type": "application/json"
}

Step 2: Extract Structured Data from Documents

FinAudit AI reads PDF, image, and structured text documents and returns normalized line-item data.

def extract_document(document_path: str, doc_type: str) -> dict:
    """
    Extract structured data from a financial document.
    doc_type: 'purchase_order' | 'goods_receipt' | 'invoice'
    """
    with open(document_path, "rb") as f:
        file_content = f.read()
 
    import base64
    encoded = base64.b64encode(file_content).decode("utf-8")
 
    payload = {
        "document": {
            "content": encoded,
            "filename": Path(document_path).name,
            "document_type": doc_type
        },
        "extraction": {
            "fields": [
                "document_number",
                "vendor_id",
                "vendor_name",
                "document_date",
                "line_items",
                "subtotal",
                "tax_amount",
                "total_amount",
                "currency"
            ],
            "normalize_amounts": True,
            "normalize_dates": True
        }
    }
 
    resp = requests.post(
        f"{FINAUDIT_BASE}/extract",
        json=payload,
        headers=HEADERS
    )
    resp.raise_for_status()
    result = resp.json()
 
    if not result["success"]:
        raise ValueError(f"Extraction failed for {document_path}: {result['error']}")
 
    return result["data"]

Step 3: Build the Three-Way Matching Engine

TOLERANCE_PCT = 0.02  # 2% tolerance for minor rounding differences
 
 
def match_line_items(
    po_items: list[dict],
    grn_items: list[dict],
    inv_items: list[dict]
) -> list[dict]:
    """Match line items across PO, GRN, and Invoice."""
    results = []
 
    # Build lookup dicts keyed by item/part number
    po_lookup = {item["item_number"]: item for item in po_items}
    grn_lookup = {item["item_number"]: item for item in grn_items}
 
    for inv_item in inv_items:
        item_num = inv_item["item_number"]
        match_result = {
            "item_number": item_num,
            "description": inv_item.get("description", ""),
            "invoice_qty": inv_item["quantity"],
            "invoice_unit_price": inv_item["unit_price"],
            "invoice_total": inv_item["line_total"],
            "status": "MATCHED",
            "exceptions": []
        }
 
        po_item = po_lookup.get(item_num)
        grn_item = grn_lookup.get(item_num)
 
        # Check 1: Item exists in PO
        if not po_item:
            match_result["status"] = "EXCEPTION"
            match_result["exceptions"].append({
                "code": "NO_PO_LINE",
                "message": f"Item {item_num} not found in purchase order"
            })
        else:
            match_result["po_qty"] = po_item["quantity"]
            match_result["po_unit_price"] = po_item["unit_price"]
 
            # Check 2: Price variance
            price_var = abs(
                inv_item["unit_price"] - po_item["unit_price"]
            ) / po_item["unit_price"]
 
            if price_var > TOLERANCE_PCT:
                match_result["status"] = "EXCEPTION"
                match_result["exceptions"].append({
                    "code": "PRICE_VARIANCE",
                    "message": (
                        f"Unit price variance {price_var:.1%}: "
                        f"PO=${po_item['unit_price']:.4f}, "
                        f"Invoice=${inv_item['unit_price']:.4f}"
                    ),
                    "variance_pct": round(price_var * 100, 2)
                })
 
            # Check 3: Quantity overbilling vs PO
            if inv_item["quantity"] > po_item["quantity"]:
                match_result["status"] = "EXCEPTION"
                match_result["exceptions"].append({
                    "code": "QTY_OVERBILL_VS_PO",
                    "message": (
                        f"Invoice qty {inv_item['quantity']} exceeds "
                        f"PO qty {po_item['quantity']}"
                    )
                })
 
        # Check 4: Quantity vs GRN (received goods)
        if not grn_item:
            match_result["exceptions"].append({
                "code": "NO_GRN_LINE",
                "message": f"Item {item_num} has no goods receipt record"
            })
            if match_result["status"] == "MATCHED":
                match_result["status"] = "REVIEW"
        else:
            match_result["grn_qty"] = grn_item["quantity"]
            if inv_item["quantity"] > grn_item["quantity"]:
                match_result["status"] = "EXCEPTION"
                match_result["exceptions"].append({
                    "code": "QTY_OVERBILL_VS_GRN",
                    "message": (
                        f"Invoice qty {inv_item['quantity']} exceeds "
                        f"received qty {grn_item['quantity']}"
                    )
                })
 
        results.append(match_result)
 
    return results
 
 
def run_three_way_match(
    po_data: dict,
    grn_data: dict,
    inv_data: dict
) -> dict:
    """Run full three-way matching and generate exception summary."""
    line_results = match_line_items(
        po_data["line_items"],
        grn_data["line_items"],
        inv_data["line_items"]
    )
 
    matched = [r for r in line_results if r["status"] == "MATCHED"]
    exceptions = [r for r in line_results if r["status"] == "EXCEPTION"]
    reviews = [r for r in line_results if r["status"] == "REVIEW"]
 
    # Calculate total overbilling risk
    overbill_risk = sum(
        r["invoice_total"]
        for r in exceptions
        if any(e["code"].startswith("QTY_OVERBILL") or e["code"] == "PRICE_VARIANCE"
               for e in r["exceptions"])
    )
 
    # Determine approval recommendation
    if exceptions:
        recommendation = "HOLD_FOR_REVIEW"
        reason = f"{len(exceptions)} exception(s) require resolution"
    elif reviews:
        recommendation = "CONDITIONAL_APPROVAL"
        reason = f"{len(reviews)} line(s) lack GRN confirmation"
    else:
        recommendation = "APPROVED_FOR_PAYMENT"
        reason = "All lines matched within tolerance"
 
    return {
        "invoice_number": inv_data["document_number"],
        "vendor": inv_data["vendor_name"],
        "invoice_total": inv_data["total_amount"],
        "currency": inv_data["currency"],
        "line_count": len(line_results),
        "matched_lines": len(matched),
        "exception_lines": len(exceptions),
        "review_lines": len(reviews),
        "match_rate": len(matched) / len(line_results) if line_results else 0,
        "overbilling_risk_amount": round(overbill_risk, 2),
        "recommendation": recommendation,
        "recommendation_reason": reason,
        "line_results": line_results
    }

Step 4: Audit Logging with FinAudit AI

After matching, log the result back through FinAudit AI to create an immutable audit trail:

def log_match_result(match_result: dict) -> str:
    """Submit match result to FinAudit for audit trail logging."""
    payload = {
        "audit_event": {
            "event_type": "PO_INVOICE_MATCH",
            "document_reference": match_result["invoice_number"],
            "vendor": match_result["vendor"],
            "outcome": match_result["recommendation"],
            "exception_count": match_result["exception_lines"],
            "financial_risk_amount": match_result["overbilling_risk_amount"],
            "currency": match_result["currency"],
            "details": match_result
        }
    }
 
    resp = requests.post(
        f"{FINAUDIT_BASE}/audit-log",
        json=payload,
        headers=HEADERS
    )
    resp.raise_for_status()
    return resp.json()["data"]["audit_id"]

Step 5: Full Pipeline

def process_invoice_batch(document_sets: list[dict]) -> list[dict]:
    """
    Process a batch of invoice document sets.
    Each set: { "po": path, "grn": path, "invoice": path }
    """
    results = []
 
    for doc_set in document_sets:
        print(f"Processing invoice: {doc_set['invoice']}")
 
        po_data = extract_document(doc_set["po"], "purchase_order")
        grn_data = extract_document(doc_set["grn"], "goods_receipt")
        inv_data = extract_document(doc_set["invoice"], "invoice")
 
        match_result = run_three_way_match(po_data, grn_data, inv_data)
        audit_id = log_match_result(match_result)
 
        match_result["audit_id"] = audit_id
        results.append(match_result)
 
        status_icon = "✅" if match_result["recommendation"] == "APPROVED_FOR_PAYMENT" else "⚠"
        print(
            f"  {status_icon} {match_result['recommendation']} — "
            f"{match_result['invoice_number']} "
            f"(${match_result['invoice_total']:,.2f} {match_result['currency']})"
        )
 
    return results
 
 
if __name__ == "__main__":
    batch = [
        {
            "po": "documents/PO-2026-00142.pdf",
            "grn": "documents/GRN-00142.pdf",
            "invoice": "documents/INV-ACME-20260405.pdf"
        },
        {
            "po": "documents/PO-2026-00143.pdf",
            "grn": "documents/GRN-00143.pdf",
            "invoice": "documents/INV-GLOBEX-20260404.pdf"
        }
    ]
 
    results = process_invoice_batch(batch)
 
    approved = sum(1 for r in results if r["recommendation"] == "APPROVED_FOR_PAYMENT")
    held = len(results) - approved
    total_risk = sum(r["overbilling_risk_amount"] for r in results)
 
    print(f"\nBatch Summary: {approved} approved, {held} held")
    print(f"Total overbilling risk identified: ${total_risk:,.2f}")

Common Exception Patterns

Based on AP automation deployments, the most frequently caught exceptions are:

ExceptionFrequencyTypical Cause
Price variance > 2%~8% of invoicesVendor price increase without updated PO
Quantity overbilling vs GRN~4% of invoicesPartial delivery not reflected in invoice
Item not in PO~2% of invoicesUnauthorized purchase or line-item substitution
Missing GRN~6% of invoicesReceiving not yet confirmed in ERP

Business Impact

Organizations that automate three-way PO matching typically report:

  • 3–5% reduction in invoice spend from catching overbilling before payment
  • 70–80% fewer manual matching hours in accounts payable
  • Same-day processing instead of 5–10 day review cycles
  • Audit-ready exception logs that satisfy SOX Section 302 requirements

Automate your PO-to-invoice matching today. Start with FinAudit AI API — free tier available for up to 100 documents per month.