Three-Way Matching in Accounts Payable Explained
Run the three-way match model locally
Clone the Formance Ledger on GitHub, run it locally, and post the goods-receipt and invoice-match transactions from this article.
Run the three-way match model locally
Clone the Formance Ledger on GitHub, run it locally, and post the goods-receipt and invoice-match transactions from this article.
A warehouse system emits the same goods-receipt event twice overnight. But the batch job counts the quantity on both deliveries, and the invoice that should fail its quantity check clears, so the payment is released the next morning. On the same run, a second invoice is paid out without a purchase order (PO) because the pipeline never required one. Both payments leave the account before anyone notices, and the errors only surface the next day when reconciliation flags them.
Three-way matching is the prepayment control that catches both duplicate goods-receipt payments and missing-PO payments before money moves. It verifies that the purchase order, the goods receipt, and the vendor invoice agree, line by line, before any payment is released.
Three-way matching in accounts payable means verifying that the purchase order, the goods receipt (or receiving report), and the vendor invoice all agree before a payment is released.
The purchase order records what you agreed to buy and at what price, the goods receipt records what physically arrived, and the invoice records what the vendor claims you owe. Payment is authorized only when all three agree, line by line.
Every match runs through five events in order, from PO creation to release-or-hold:
If an enterprise resource planning (ERP) system owns this control, it may be configured by a finance team and remain largely invisible to engineering. A fintech that moves money programmatically has to run the match inside the payment pipeline itself against events from procurement, logistics, and vendor systems.
Running the match inside the payment pipeline raises the question of where three-way matching ends and reconciliation begins.
In accounts payable, matching and ledger reconciliation are two distinct controls that operate at different times and on different data.
Matching is a prepayment control that compares the PO, receipt, and invoice before payment is processed, and any mismatch blocks the payment. Ledger reconciliation is a post-payment control that compares your ledger to bank and payment service provider statements after funds have moved. A mismatch there produces a reconciliation break that requires investigation.
| Dimension | Three-way matching | Ledger reconciliation |
| Control type | Pre-payment control | Post-payment control |
| Timing | Runs before funds move | Runs after funds have moved |
| Data compared | PO, goods receipt, and vendor invoice | Internal ledger vs. bank and PSP statements |
| Failure caught | Invoice fraud, duplicate goods-receipt events, and out-of-tolerance vendor billing | Settlement errors, PSP-side discrepancies, and payments that moved without being recorded |
| What moves on failure | Nothing, because the payment is blocked at the gate | Money has already moved, but a reconciliation break is opened for investigation |
Matching catches invoice fraud, duplicate goods-receipt events, and out-of-tolerance vendor billing at the gate, before payment leaves the account. In one DOJ-prosecuted accounts payable fraud case, Robert DiNoto invoiced a Maryland manufacturer roughly $257,181 for 55-gallon steel drums his New York company never delivered, and was ordered to forfeit $514,352 at sentencing.
A three-way match would have failed on each of those invoices during the goods-receipt check because the drums were never received.
Reconciliation addresses a different class of problems, such as settlement errors, PSP-side discrepancies, and payments that moved without being recorded. Picture a duplicate ACH file reprocessed by the bank overnight. The vendor gets paid twice, and the next day reconciliation flags it as a ledger-vs-statement break after the second payment has already cleared.
The money is already gone, which is why the match has to hold at the gate rather than post-payment. Holding at the gate only works when the match outcome lands somewhere the accounting system can trust.
Three-way match results post through a clearing account that holds received-but-unmatched value before an approved amount moves into vendor payables.
The sequence has three steps:
An approved match generates a double-entry posting the moment it resolves, while an unresolved match remains visible as a nonzero balance on the clearing account. These postings record the accounting state, independent of the connected bank, PSP, exchange, or custodian that holds or moves the actual funds and executes the payment.
Accruing at goods receipt, rather than at match resolution, is what makes unmatched exposure queryable. Sum the clearing accounts, and you have exactly the value received but not yet matched to an invoice. This operational ledger feeds the general ledger in your ERP on the finance side, and it never substitutes for it.
Without this architecture, a subledger argument would apply. Match state tracked outside the system that enforces it becomes untrustworthy, and vendor payables would be a figure the accounting team defends rather than one the ledger produces. Posting through the clearing account avoids that gap entirely.
Each posting is written in Numscript, Formance’s purpose-built language for money that describes the transaction's intent in a form both engineering and finance read.
Consider a fintech that receives 1,200 units at $850 each against a $1,020,000 purchase order, PO-8841, from vendor Acme.
The platform-side accrual counter-entry is @platform:procurement:receivedGoods, received-but-unmatched value for the PO is held in @platform:procurement:purchaseOrders:8841:receiptClearing. The amount owed to Acme is held in @merchants:acme:payable.
The goods receipt moves $1,020,000 from the platform-side accrual account into the PO's clearing account:
// GOODS_RECEIPT
// Event: accrue received goods for PO-8841 into receipt clearing
send [USD/2 102000000] (
source = @platform:procurement:receivedGoods allowing unbounded overdraft
destination = @platform:procurement:purchaseOrders:8841:receiptClearing
)
set_tx_meta("event_type", "goods_receipt")
set_tx_meta("po_id", "8841")
The invoice arrives at the same quantity and price, the match resolves to matched, and a separate invoice-match transaction clears the accrual into Acme's payable:
// INVOICE_MATCH
// Event: approve the invoice match for PO-8841 and recognize Acme's payable
send [USD/2 102000000] (
source = @platform:procurement:purchaseOrders:8841:receiptClearing
destination = @merchants:acme:payable
)
set_tx_meta("event_type", "invoice_match")
set_tx_meta("po_id", "8841")
After the second posting, @platform:procurement:purchaseOrders:8841:receiptClearing is back to zero.
Had the invoice arrived at $890 per unit, outside the price tolerance, the second posting would never fire. The clearing account still holds $1,020,000, and the invoice remains in an outside-tolerance hold.
A query for nonzero clearing balances immediately surfaces PO-8841, without a separate exception table to keep in sync. The clearing-account model is what the checklist below verifies at the pipeline level, one dimension at a time.
Five checks are required for a three-way match to post cleanly to the ledger. These include event ordering, tolerance handling, where the match state lives, how decisions are recorded, and whether approval and posting remain atomic.
The five-event sequence (PO, receipt, invoice, match, and release-or-hold) must handle out-of-order arrivals, late invoices, and duplicate deliveries without producing duplicate matches or duplicate payments. Assume every queue is at-least-once, and let idempotency keys on the match evaluation itself absorb the retries the network guarantees you will see.
The price and quantity comparisons are against declared tolerances and are not hardcoded thresholds in the match code. A finance team can change the rule without a code deploy. Effective-dated rules allow a new tolerance to apply from a specific date forward, while every in-flight match keeps the version it was evaluated under.
Goods receipt accrues into a per-PO clearing account. Invoice match clears the accrual into vendor payables. The clearing balance is the exception queue because a stalled invoice becomes a nonzero balance on a specific account rather than a status flag buried in application code.
Every match evaluation, approval, hold, and override lands as a new event on an append-only log, never as a mutation on a status field. Auditors can trace who approved which invoice, against which tolerance version, and against which receipt, without asking engineering to reconstruct history from application logs.
Resolving the match and moving the money in the ledger happen in the same transaction, so the payable balance can never disagree with the match state. A human override commits the same way. The approval and the posting it authorizes land together, or neither lands.
Passing the five checks matters because it changes what three-way matching is. Matching stops being a workflow the application asserts and becomes a control the ledger enforces once its outcomes live where the money lives.
The clearing account balance is the unmatched-value figure, and the vendor payable balance is the approved-but-unpaid figure. No status column has to be reconciled against a posting, because the posting is the record of the match decision.
The event sequence, the tolerance rules, and the clearing-account model all serve one outcome: to ensure that the three-way match state and the ledger state are no longer two things that can disagree.
The warehouse system incident that resulted in duplicate payments, invoices paid without a PO, and exposure that surfaces only when reconciliation catches up to a bad payment illustrates what happens when match state and the ledger fall out of alignment.
Formance is the programmable ledger that anchors three-way match logic directly to double-entry postings, something you can run rather than architect from scratch.