Complete documentation of PO-GRN-Invoice matching process, variance handling, tolerance thresholds, and automatic reconciliation for accounts payable.
Three-way matching is a critical accounts payable control that compares three documents before authorizing payment: the Purchase Order (what was ordered), the Goods Receipt Note (what was received), and the Vendor Invoice (what is being billed).
What was ordered
What was received
What is billed
Prevents payment for goods not ordered, not received, or at incorrect prices. Reduces fraud, billing errors, and overpayments.
For a successful match, the following must align across all three documents:
| Criteria | PO | GRN | Invoice | Tolerance |
|---|---|---|---|---|
| Quantity | Ordered Qty | Accepted Qty | Invoiced Qty | 0% (exact match) |
| Unit Price | Agreed Price | N/A | Billed Price | +/- 2% |
| Line Total | PO Line Amount | N/A | Invoice Line Amount | +/- 2% |
| Tax Amount | Estimated Tax | N/A | Actual Tax | +/- 1% |
| Total Amount | PO Total | N/A | Invoice Total | +/- 2% |
Tolerance thresholds are configurable in config.system_settings with keys like MATCH_PRICE_TOLERANCE_PCT and MATCH_QTY_TOLERANCE_PCT.
Example matching scenario for "Steel Rods - 100 units @ $50 each":
| Status | Condition | Action Required |
|---|---|---|
| MATCHED | All criteria within tolerance | Auto-approve for payment |
| PARTIAL_MATCH | Some items match, others have minor variance | Review and manually approve |
| MISMATCH | Significant variance beyond tolerance | Investigate, create debit note if needed |
| PENDING | GRN or Invoice not yet received | Wait for documents |
When a vendor invoice is entered into the system, it links to the PO and optionally to specific GRNs.
INSERT INTO logistics.vendor_invoices (invoice_number, po_id, vendor_id, grn_id, invoice_date, total_amount, status) VALUES ('INV-2026-0456', @poId, @vendorId, @grnId, NOW(), 4949.00, 'PENDING_MATCH');
The matching process can be triggered automatically on invoice entry or via scheduled batch job.
public async Task PerformThreeWayMatchAsync(long invoiceId) { var invoice = await GetInvoiceWithItemsAsync(invoiceId); var po = await GetPOWithItemsAsync(invoice.PoId); var grn = await GetGRNWithItemsAsync(invoice.GrnId); var matchResult = CompareDocuments(po, grn, invoice); await SaveMatchResultAsync(matchResult); }
Each invoice line is compared against corresponding PO and GRN lines.
SELECT poi.id AS po_item_id, poi.quantity AS po_qty, poi.unit_price AS po_price, gi.accepted_qty AS grn_qty, ii.quantity AS inv_qty, ii.unit_price AS inv_price, ABS(poi.unit_price - ii.unit_price) / poi.unit_price * 100 AS price_variance_pct, CASE WHEN gi.accepted_qty = ii.quantity AND ABS(poi.unit_price - ii.unit_price) / poi.unit_price <= 0.02 THEN 'MATCHED' ELSE 'MISMATCH' END AS line_status FROM po.po_items poi JOIN logistics.grn_items gi ON gi.po_item_id = poi.id JOIN logistics.invoice_items ii ON ii.po_item_id = poi.id WHERE ii.invoice_id = @invoiceId;
Aggregate line-level results to determine overall match status.
private MatchStatus CalculateOverallStatus(List<LineMatchResult> lineResults) { if (lineResults.All(l => l.Status == "MATCHED")) return MatchStatus.Matched; if (lineResults.Any(l => l.Status == "MISMATCH") && lineResults.Sum(l => l.VarianceAmount) > toleranceAmount) return MatchStatus.Mismatch; return MatchStatus.PartialMatch; }
Store the matching result for audit and workflow processing.
INSERT INTO logistics.three_way_match (po_id, grn_id, invoice_id, match_status, variance_amount, variance_reason, matched_at) VALUES (@poId, @grnId, @invoiceId, 'MATCHED', 49.00, 'Price variance 1% within tolerance', NOW());
Based on match result, update invoice status to proceed with approval or flag for review.
UPDATE logistics.vendor_invoices SET status = CASE WHEN @matchStatus = 'MATCHED' THEN 'APPROVED_FOR_PAYMENT' WHEN @matchStatus = 'PARTIAL_MATCH' THEN 'PENDING_REVIEW' ELSE 'DISPUTED' END WHERE id = @invoiceId;
Invoice > GRN: Create a debit note for unbilled quantity. Only pay for what was received.
Invoice Price > PO Price: Escalate for approval or reject. Document in variance_reason.
Invoice received before goods? Mark as PENDING until GRN is created. Never pay before receiving.
-- Create debit note for over-invoiced quantity INSERT INTO logistics.debit_notes (debit_note_number, vendor_id, grn_id, reason, amount, status, created_by) VALUES (GenerateDebitNoteNumber(), @vendorId, @grnId, 'Invoice quantity 100 exceeds received quantity 98', 100.00, -- 2 units @ $50 'PENDING', @userId);
| Setting Key | Default Value | Description |
|---|---|---|
MATCH_PRICE_TOLERANCE_PCT |
2.0 | Price variance tolerance percentage |
MATCH_QTY_TOLERANCE_PCT |
0.0 | Quantity must match exactly |
MATCH_TAX_TOLERANCE_PCT |
1.0 | Tax calculation variance tolerance |
MATCH_AUTO_APPROVE |
true | Auto-approve matched invoices for payment |
MATCH_REQUIRE_GRN |
true | Require GRN before invoice can be matched |