Procurement in ERP: PR to PO to Receipt to 3-Way Match

Photo by cottonbro studio

Photo by cottonbro studio
Procurement is where an ERP earns its keep in hard cash. Every other module mostly organizes information; the procure-to-pay chain is the one that decides whether money leaves the company for goods that actually arrived at the price actually agreed. When I designed purchasing flows alongside the AP module I built for ANCoraPRO on NestJS and PostgreSQL, the client's previous process was a WhatsApp message to a supplier and an Excel row — and about twice a year, an invoice got paid for goods nobody could find in the warehouse.
The fix is a document chain with four links — purchase requisition, purchase order, goods receipt, vendor invoice — and a matching engine that refuses to release payment until the story they tell is consistent. This post walks the chain link by link, with the schema decisions and edge cases that separate a procurement module that works from one that gets worked around.
The core design insight is that each document in the chain answers a different question, and they must remain separate records even when one person handles all of them. The requisition records intent, the purchase order records commitment, the goods receipt records physical reality, and the invoice records the vendor's claim. Collapse any two of them into one record and you lose the ability to detect their disagreement — which is the entire point:
The procure-to-pay document chain
PR ----------> PO ----------> GR ----------> AP Invoice
(request) (commitment) (reality) (claim)
PR : "we need 20 office chairs" -- internal intent
PO : "we order 20 chairs @ 850,000" -- legal commitment
GR : "18 chairs arrived, 2 damaged" -- physical truth
INV: "vendor bills 20 chairs" -- payment claim
3-way match = PO vs GR vs INV
-> pay for 18, dispute 2, never pay for what never arrivedEvery line in every downstream document carries a foreign key to its upstream line: PO lines reference PR lines, GR lines reference PO lines, invoice lines reference PO lines. That line-level linkage, not document-level, is what makes partial deliveries and split invoices tractable later.
The PR exists so that asking for something is easy and committing money is controlled. Any employee can raise one: free-text description allowed, catalog item preferred, estimated price auto-filled from the last purchase. Friction here pushes people back to WhatsApp — the PR form should take under two minutes, mobile included, because the person who notices the forklift needs a part is standing next to the forklift.
Approval is where the spending policy lives, routed by amount band and department through a configurable approval matrix rather than hardcoded chains. One decision worth making explicit: approval of a PR authorizes the need, not the supplier or the final price. Purchasing converts approved PRs into POs, choosing or negotiating the vendor — separating who may ask from who may commit is a basic fraud control.
Pro tip: aggregate approved PR lines by item across departments before converting to POs. Three departments each ordering ten boxes of paper is one PO for thirty with a better price — and this consolidation screen is consistently the first place a procurement module shows measurable savings.
The PO is usually a legally meaningful commitment to the vendor, so its lifecycle needs to be strict in ways the PR does not. The rules I implement:
The GR is the warehouse confirming what physically arrived: quantities counted, condition noted, posted against specific PO lines. It does two jobs at once — it appends stock movements into inventory, and it creates the received-quantity evidence the match engine will compare against the invoice. Partial deliveries are normal, so multiple GRs per PO line must be first-class, with the running received total maintained per line.
Design the GR screen for the receiving dock, not for accounting: big quantity fields, the PO lines pre-listed so the receiver confirms rather than types, a damaged-quantity field that creates a vendor return draft automatically. The quality of your entire downstream match depends on a warehouse worker finding this screen faster than a paper form.
Never allow receiving against no PO as a routine path. The emergency happens — a genuine urgent delivery with paperwork to follow — so provide an exception receipt that requires a named approver and creates a PO retroactively within a deadline. If unreferenced receipts are frictionless, the entire chain unravels within months, because every receipt becomes an exception.
Three-way matching compares the PO, the accumulated goods receipts, and the vendor invoice before payment is approved — the standard control for catching over-billing, under-delivery, and fraud. The three checks, what each catches, and where tolerances belong:
| Check | Compares | Catches | Tolerance |
|---|---|---|---|
| Quantity | Invoiced qty vs received qty, per PO line | Billing for goods not (yet) delivered; duplicate billing across split invoices | Zero — quantity is countable; never pay for more than arrived |
| Price | Invoice unit price vs PO unit price | Price creep, wrong price list, currency slip-ups | Small percentage band (0.5 to 2 percent) absorbs rounding; anything above queues for review |
| Existence | Invoice line vs an open, approved PO line | Invoices for things never ordered — the classic fraud vector | None — no PO reference, no automatic path to payment |
Run the match per invoice line at posting time and store the result as a status on the line. Matched lines flow to payment scheduling untouched by human hands; exceptions land in a queue that shows the three documents side by side with the discrepancy highlighted. The metric to watch is touchless rate — the share of invoice lines that match automatically. Mature setups reach well above half, and every exception type that recurs points at an upstream fix: receivers skipping GRs, price lists out of date, vendors free-styling units of measure.
// NestJS: match result computed per invoice line, never per document
type MatchStatus = 'MATCHED' | 'PRICE_VARIANCE' | 'QTY_OVER_BILLED'
| 'NOT_RECEIVED';
function matchInvoiceLine(
poLine: PoLine,
receivedQty: number, // SUM of GR lines for this PO line
invLine: InvoiceLine,
tolerancePct: number, // e.g. 1.0 = 1% price tolerance
): MatchStatus {
if (receivedQty === 0) return 'NOT_RECEIVED';
if (invLine.qty > receivedQty) return 'QTY_OVER_BILLED';
const priceDeltaPct =
Math.abs(invLine.unitPrice - poLine.unitPrice)
/ poLine.unitPrice * 100;
if (priceDeltaPct > tolerancePct) return 'PRICE_VARIANCE';
return 'MATCHED';
}
// MATCHED -> auto-release for payment scheduling
// anything else -> exception queue with PO + GR side by sideThe happy path is an afternoon of work. These four scenarios are the actual project:
Before calling a procurement module production-ready, I verify these five in order:
A procurement module is four documents and one referee. Keep the documents separate so they can disagree, link them at line level so partial reality fits, and let the match engine — not habit or hierarchy — decide which invoices touch money without a human. Build it this way and the system pays exactly for what arrived; build it looser and you have digitized the WhatsApp process with extra steps.