Accounts payable is where a growing distributor quietly loses money: a supplier bills 500 units when 480 arrived, a price creeps up two percent, an invoice gets paid twice because it came in by email and by post. Three-way matching (purchase order, goods receipt, invoice) is the control that stops this, and in a custom ERP it is a small, well-defined piece of software. Built badly, it becomes a queue of exceptions nobody clears.
I run Loop Origin, a software studio, and as a Principal Software Engineer I have spent nine years building order, inventory and finance modules in Laravel, Vue, Nuxt and PostgreSQL. I wrote this with help from AI tools and checked every query and code sample myself. This guide covers the data model, matching rules, tolerances, exception workflow and the failure modes I see when teams automate AP.
What is three-way invoice matching?
Direct answer: Three-way matching approves a supplier invoice for payment only when each invoice line agrees with the purchase order line (price, item) and with the goods actually received (quantity), within set tolerances. Lines that agree are auto-approved; lines that do not become exceptions routed to a named owner with the reason attached.
Two-way, three-way or four-way?
| Match type | Documents compared | Catches | Misses | Fits |
|---|---|---|---|---|
| Two-way | PO and invoice | Price changes, unordered items | Billing for goods that never arrived | Services, subscriptions, utilities |
| Three-way | PO, goods receipt (GRN) and invoice | Price changes, short deliveries, overbilling | Goods that arrived but failed inspection | Most physical goods |
| Four-way | PO, GRN, inspection result and invoice | Everything above plus rejected stock | Nothing in this list, at the cost of an extra step | Regulated, food, pharma, high-value parts |
Most mid-market importers and distributors need three-way for stock items and two-way for services. Support both from one engine with a match type on each PO line, not two separate code paths.
The data model
The match runs on lines, never on document totals. A header-level comparison hides a short delivery on one line behind an overcharge on another.
CREATE TABLE po_lines ( id bigserial PRIMARY KEY, po_id bigint NOT NULL REFERENCES purchase_orders(id), sku text NOT NULL, qty_ordered numeric(14,3) NOT NULL, unit_price numeric(14,4) NOT NULL, match_type text NOT NULL DEFAULT 'three_way' -- two_way | three_way ); CREATE TABLE grn_lines ( id bigserial PRIMARY KEY, grn_id bigint NOT NULL REFERENCES goods_receipts(id), po_line_id bigint NOT NULL REFERENCES po_lines(id), qty_received numeric(14,3) NOT NULL, received_at timestamptz NOT NULL ); CREATE TABLE invoice_lines ( id bigserial PRIMARY KEY, invoice_id bigint NOT NULL REFERENCES supplier_invoices(id), po_line_id bigint REFERENCES po_lines(id), qty_billed numeric(14,3) NOT NULL, unit_price numeric(14,4) NOT NULL, match_status text NOT NULL DEFAULT 'pending', -- pending | matched | exception | approved_override match_reason text ); -- one supplier cannot submit the same invoice number twice CREATE UNIQUE INDEX uq_supplier_invoice_no ON supplier_invoices (supplier_id, lower(regexp_replace(invoice_no, '[^A-Za-z0-9]', '', 'g'))); Two details matter. The duplicate check normalizes the invoice number, because "INV-0042" and "inv 0042" are the same bill. And quantity matching must compare against what has been received minus what has already been billed, or partial deliveries get paid twice.
-- quantity still billable per PO line SELECT p.id, COALESCE(SUM(g.qty_received), 0) AS received, COALESCE((SELECT SUM(i.qty_billed) FROM invoice_lines i WHERE i.po_line_id = p.id AND i.match_status IN ('matched','approved_override')), 0) AS billed FROM po_lines p LEFT JOIN grn_lines g ON g.po_line_id = p.id GROUP BY p.id; If you already keep a stock ledger, the goods receipt should be the same event that posts stock in, as in the multi-warehouse stock ledger design. One receipt, two consumers.
Matching rules and tolerances
Tolerances are business policy, so store them as data per supplier or category, with a company default. Hard-coding "2%" is the most common mistake I find in inherited AP code.
| Check | Compares | Typical rule | On failure |
|---|---|---|---|
| Item | Invoice line SKU vs PO line SKU | Exact, or via a supplier SKU map | Exception: unknown item |
| Price | Invoice unit price vs PO unit price | Within a percentage and an absolute cap, whichever is smaller | Exception: price variance |
| Quantity | Qty billed vs (received − already billed) | Billed must not exceed billable | Exception: over-billed or not received |
| Duplicate | Supplier + normalized invoice number | Must be unique | Reject at intake |
| Total | Sum of lines + tax vs invoice total | Within a rounding amount | Exception: header mismatch |
The engine in Laravel stays small:
final class ThreeWayMatcher { public function __construct(private TolerancePolicy $policy) {} public function match(InvoiceLine $line, float $billable): MatchResult { $po = $line->poLine; if (! $po) { return MatchResult::exception('no_po_line'); } $tol = $this->policy->for($line->invoice->supplier_id, $po->sku); $priceDiff = abs($line->unit_price - $po->unit_price); $priceLimit = min($po->unit_price * $tol->pricePct / 100, $tol->priceAbs); if ($priceDiff > $priceLimit) { return MatchResult::exception('price_variance', compact('priceDiff', 'priceLimit')); } if ($po->match_type === 'three_way' && $line->qty_billed > $billable + $tol->qtyAbs) { return MatchResult::exception('qty_exceeds_received', [ 'billed' => $line->qty_billed, 'billable' => $billable, ]); } return MatchResult::matched(); } } Run it inside a database transaction that locks the PO lines involved (SELECT ... FOR UPDATE), so two invoices for the same delivery cannot both pass the quantity check at the same moment. When an invoice matches, publish the "approved for payment" event through an outbox so the accounting sync cannot be lost; the pattern is in transactional outbox in Laravel.
The exception workflow is the real product
Auto-matching is the easy half. What decides whether AP trusts the system is how exceptions are handled:
- Every exception has a reason code and the numbers. "Price variance: billed 104.00, PO 100.00, limit 2.00" lets a clerk act without opening three screens.
- Route by reason. Price variances go to the buyer who raised the PO. Quantity problems go to the warehouse. Unknown items go to purchasing.
- Overrides are explicit and logged. An override needs a user, a reason and, above an amount, a second approver. Store it in an append-only log, as in immutable audit trails in Postgres.
- Re-match automatically. When a late goods receipt arrives, re-run matching on that PO's pending invoice lines instead of waiting for someone to click.
- Age the queue. Show exceptions by age and by owner. A queue nobody owns becomes a queue nobody clears.
Failure modes I see in production
- Header-only matching. Totals agree, lines do not. Always match lines.
- Ignoring partial deliveries. Comparing billed to ordered instead of to received pays for goods still at sea.
- Unit of measure drift. The PO is in cartons, the invoice in pieces. Store a conversion per supplier SKU and convert before comparing.
- Currency and rounding. Compare in the PO currency and round only at the final step.
- Credit notes as negative invoices. Treat them as their own document type linked to the original invoice, or quantity math breaks.
- Tolerance creep. Someone raises tolerances to clear the queue. Make tolerance changes an audited, approved action.
Build, buy or extend?
| Option | Good fit | Trade-off |
|---|---|---|
| AP module in an off-the-shelf ERP | Standard purchasing, few suppliers, standard units | You adopt its workflow and its exception screens |
| Standalone AP automation SaaS | Heavy invoice volume, OCR intake needed | Another integration to keep in sync with your PO and stock data |
| Custom module in your own ERP | PO and receiving data already live in your system; unusual units, landed costs or approval rules | You own the code and its maintenance |
A custom module makes sense when purchasing and receiving already run in software you own. If they do not, fix that first; matching cannot be better than the receipt data it reads.
Rollout checklist
- [ ] PO lines carry match type, unit of measure and supplier SKU mapping
- [ ] Goods receipts are recorded per PO line, with timestamps
- [ ] Duplicate invoice check uses a normalized invoice number per supplier
- [ ] Tolerances stored as data, with an audited change process
- [ ] Matching locks PO lines and runs in a transaction
- [ ] Every exception has a reason code, numbers and an owner
- [ ] Overrides need a reason, and a second approver above a set amount
- [ ] Late receipts trigger automatic re-matching
- [ ] Run in shadow mode for a month: compute results, but keep the old process paying
Frequently asked questions
What tolerance should we set for price variance?
There is no universal number. Start from what your finance team already accepts by hand, set both a percentage and an absolute cap, and review the exception data after a month.
Can we match invoices that cover several POs?
Yes, because matching runs per line. Each invoice line points to its own PO line, so one invoice can span several POs.
How do we handle freight and other charges not on the PO?
Model them as separate charge lines with their own approval rule, or allocate them to landed cost. Do not let them fail the item match.
Does three-way matching need OCR?
No. OCR only helps with capturing invoice data. Matching works the same whether lines come from OCR, a supplier portal or manual entry.
What happens if goods arrive after the invoice?
The line stays an exception with "not received", then re-matches automatically when the receipt is posted.
How long does a custom matching module take to build?
It depends on how clean your PO and receiving data is. Estimate after you review that data, not before.
Follow & Connect
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)
