# Hands-on: build a three-way match agent that cannot release payments

> The hard part of invoice matching is not the comparison but the exceptions, and a good agent has to know when to stop.

Original: https://fdetimes.net/en/guides/three-way-match-agent-no-payment-rights/

A supplier delivers 46 of 50 boxes but invoices for all 50. The remaining four may be on the next truck, or they may never arrive. That is the example Lleverage, a company that automates matching, uses to describe the kind of exception that breaks invoice-matching systems.

Anyone can write a comparison of three numbers in ten minutes. The real work lies elsewhere.

According to 2025 APQC data cited by the vendor Peakflo, organisations using rules-based matching achieve straight-through processing, with no human touch, on only 52-58% of invoices.

The rest, nearly half of all invoices, is the work an FDE is paid to solve.

This guide walks through building a three-way match agent in the order it should be done at a client site: rules first, exceptions next, the agent last, and a hard boundary that the agent may not release payments. The code below is plain Python, simplified to illustrate the logic. It is not tied to any ERP's API.

## What you will build, and what you need

The output is a small pipeline. It takes three kinds of document (purchase orders, goods receipts and invoices), compares them line by line, assigns each line a status, and passes to the agent only the lines the rules cannot resolve. The agent proposes a line mapping and explains its reasoning. A person approves.

You need Python 3.10 or later, a sample dataset of a few purchase orders, goods receipts and invoices (you can make your own), and access to an LLM that supports tool use. Most important of all, you need to know which tolerances the client has configured in their ERP.

The order is not arbitrary. In its guidance on building agents, Anthropic advises finding the simplest solution possible and adding complexity only when it is truly needed. For invoice matching, the simplest solution is a comparison function.

## Step 1: normalise to lines

Everything happens at line level, not document level. Bring all three document types into the same structure:

```python
from dataclasses import dataclass

@dataclass
class Line:
    doc_id: str          # PO number, goods receipt number or invoice number
    po_line: str | None  # the PO line this line points to (may be missing)
    sku: str
    qty: float
    unit_price: float
```

Check: print every invoice line with an empty `po_line`. These are the first candidates for step 4, because the supplier either left out the reference or merged several lines into one.

## Step 2: match with rules and tolerances

The large ERPs do exactly this. Sage X3's documentation describes two comparisons: the invoice price against the purchase order price, and the quantity received against the quantity ordered, each with a configurable tolerance threshold.

Beyond the threshold, the system warns or blocks. You rewrite that logic, adding a flag for invoices that bill less than was ordered:

```python
def check_line(po: Line, gr_qty: float, inv: Line,
               price_tol=0.02, qty_tol=0.0) -> list[str]:
    issues = []
    if gr_qty == 0:
        issues.append("MISSING_GR")
    elif inv.qty > gr_qty * (1 + qty_tol):
        issues.append("QTY_OVER_RECEIPT")
    elif inv.qty < po.qty:
        issues.append("PARTIAL")       # bills exactly what was received, less than ordered
    if abs(inv.unit_price - po.unit_price) > po.unit_price * price_tol:
        issues.append("PRICE_VARIANCE")
    return issues
```

Try a hypothetical example. The purchase order is for 50 units at a unit price of 10.00, with a 2% price tolerance. The invoice shows a unit price of 10.15, a 1.5% variance: it passes. But the invoice bills 50 while the goods receipt shows only 46: this line gets the `QTY_OVER_RECEIPT` flag, exactly the case Lleverage describes.

Check: run the function over all the sample data and count the share of lines with no flags. That number is your baseline. Every later improvement must be measured against it.

## Step 3: how should each exception be handled?

A list of flags is not yet a decision. You need to turn flags into statuses, and each type of exception has its own treatment.

A missing goods receipt is a common case: according to Lleverage, the warehouse has not yet recorded it, or recorded it against the wrong order line. SAP S/4HANA Cloud has a dedicated tolerance key, DW, which triggers when an invoice is posted while the goods receipt quantity is zero.

SAP recommends configuring DW as a payment block rather than an error, so that invoices arriving before the goods receipt can still be released. Keep to the same spirit: a `HOLD` status, then an automatic recheck when a new goods receipt arrives.

Partial deliveries need careful distinction. Peakflo argues that an agent should separate legitimate partial deliveries from genuine discrepancies, and only genuine discrepancies should go to a human approver.

One reasonable line to draw: an invoice that bills exactly what was received, on a PO that is still open, is legitimate, and the rest will come later. If the PO is closed, the shortfall will never be delivered, so a person needs to handle it. An invoice that bills more than was received, like the 46-of-50 case, is always a discrepancy.

A changed price is the case tolerances cannot save. Lleverage states plainly that tolerances help with rounding, not with this, because the error lies in the purchase order itself, which is out of date. This case needs a person: someone has to correct the PO or reject the new price.

```python
def decide(issues: list[str], po_open: bool) -> str:
    if not issues:
        return "MATCHED"
    if "PRICE_VARIANCE" in issues:
        return "ESCALATE"   # the PO may be out of date, even when the GR is missing
    if "QTY_OVER_RECEIPT" in issues:
        return "ESCALATE"   # billed more than was received
    if issues == ["MISSING_GR"]:
        return "HOLD"       # payment block, releasable once a goods receipt arrives
    if issues == ["PARTIAL"]:
        return "MATCHED" if po_open else "ESCALATE"
    return "ESCALATE"       # any unusual combination: hand it to a person
```

The order of the branches is deliberate. A line that both lacks a goods receipt and has a price variance becomes `ESCALATE`, not `HOLD`: waiting for the warehouse to record the delivery will not fix a mispriced purchase order, so holding the invoice only delays the work the buyer has to do.

Check: no status in this function means "pay". `MATCHED` only means the three documents agree.

## Step 4: the agent steps in only where rules give up

Where rules truly give up is line structure. According to Lleverage, line-level rules assume one invoice line corresponds to one purchase order line, so everything else falls through.

An invoice that merges three PO lines into one, or splits one line into two deliveries, needs someone to read the item description, SKU and quantity, then map each line to the right PO line and the right goods receipt. That is the agent's job.

Anthropic recommends "poka-yoke" for tools, meaning shaping the parameters so they are hard to misuse. Applied here, the agent has only two tools, and neither touches payments:

```python
TOOLS = [
  {"name": "propose_line_mapping",
   "params": {"invoice_line": "str", "po_line": "str",
              "gr_id": "str", "reason": "str"}},
  {"name": "record_match_result",
   "params": {"invoice_line": "str",
              "status": ["MATCHED", "HOLD", "ESCALATE"],
              "reason": "str"}},
]
```

This is a simplified description; you will need to translate it into the tool format of the LLM you use. The detail worth noting is that `status` is a closed enum. The agent cannot invent an "APPROVED_FOR_PAYMENT" status, because none exists.

Once the agent proposes a mapping, run `check_line` and `decide` again on the result. The agent handles the reading and interpretation; the comparison is still decided by code. Any proposal that fails the rules automatically becomes `ESCALATE`.

**Key point:** The agent may read, map and explain; the decision to pay always stays with a person.

## Step 5: test with the exceptions themselves

Lleverage draws the line very clearly: releasing a payment is never the agent's call. Turn that sentence into a test. Write a small set of cases covering the four exceptions above, the case with both a missing goods receipt and a price variance, plus one perfectly matching line, then assert each output status.

Add one more test: scan every tool the agent is allowed to call and assert that none writes to the payments table. It sounds obvious, but it is what you show when the client's auditor asks, "How do you prove the machine cannot pay on its own?"

## Common mistakes

The first mistake is bringing the agent in from the start and letting it handle lines that a subtraction would have resolved. You pay more, lose explainability and have no baseline to measure against.

The second is raising tolerances to lift the match rate. The number looks better, but price-change cases slip through instead of reaching the person who should fix the PO.

The third is rejecting outright invoices that lack a goods receipt. The supplier has to resend, finance has to re-enter, when the warehouse was simply a day late recording the delivery. SAP itself recommends a releasable block.

## How this skill shows up at a client site

At a client site you will rarely write `check_line` from scratch. The tolerances already live in the ERP, and the first task is to read that configuration and ask the accountants why they chose those numbers. Your agent must respect the same thresholds, or the two systems will argue over the same invoice.

The second task is to take a week of real invoices, run the rules, then sit with the accounts payable staff and classify each exception by hand. That classification shows where the agent is worth deploying, and becomes your first eval set.

If you want to move into an FDE role, look for job descriptions that mention "accounts payable", "ERP integration" or "human-in-the-loop". On your CV, do not write "built AI agents for finance".

Write instead that you broke exceptions down by type, measured straight-through processing before and after, and designed the tools so the agent had no way to touch payments. FDE recruiters want to see that you know where a machine can decide on its own and where it cannot.

As for those four boxes that never arrived, they do not need a smarter agent. They need a system willing to hold the invoice, state the reason clearly, and wait for someone with authority to decide.

**Try this week:**

- Take 20 anonymised invoices from a client (or make your own), run check_line and count how many lines fall into each type of exception
- Write test cases for the four exceptions in this article and for the case with both a missing goods receipt and a price variance, plus a test asserting that no agent status leads to a payment
- Ask the client's finance team three questions: what price tolerance is set, who releases payment blocks, and how invoices that arrive before the goods receipt are handled today

## Sources

- [Three-way match — Procurement Glossary | LightSource](https://lightsource.ai/glossary/three-way-match)

- [Matching tolerance (Sage X3 documentation)](https://online-help.sagex3.com/erp/12/en-us/Content/FCT/GESMAT.htm)

- [Supplier invoice blocked or in error due to missing goods receipt in 3-way match in SAP S/4 HANA Cloud Public Edition](https://userapps.support.sap.com/sap/support/knowledge/en/3769147)

- [Three-Way Matching Automation: The Exceptions That Break It](https://www.lleverage.ai/company/blog/three-way-matching-automation)

- [Autonomous PO Matching: Can AI Agents Match Invoices Without Humans?](https://peakflo.co/blog/ai-agents-autonomous-po-matching)

- [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)
