Key Takeaway
Procurement is where an ERP earns its keep in cash, because the procure-to-pay chain decides whether money leaves the company for goods that actually arrived. The three-way match between purchase order, goods receipt and invoice is the control that makes that verifiable rather than trusted.
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 Document Chain: Four Documents, Four Different Truths
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.
Purchase Requisition: Cheap to Raise, Routed by the Matrix
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.
Purchase Order: The Commitment Record
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:
- Immutable after sending — once transmitted to the vendor, price and quantity changes happen through a versioned amendment that requires re-approval if the total crosses an approval band, never through silent edits.
- Status from facts, not clicks — OPEN, PARTIALLY_RECEIVED, RECEIVED, CLOSED are derived from linked GR quantities, not set by hand. Manually closable POs become a place where undelivered goods hide.
- Commitment visibility — open PO totals per budget line surface in dashboards immediately. Finance teams call this committed spend, and seeing it before invoices arrive is half the value of the module.
- Expiry discipline — POs untouched for a configurable period (90 days works) raise a review task. Stale open POs distort committed spend and eventually become surprise deliveries.
Goods Receipt: Where Physical Reality Enters the System
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.
The 3-Way Match: The Engine That Guards Payment
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 sideEdge Cases Your Match Engine Must Survive
The happy path is an afternoon of work. These four scenarios are the actual project:
- Partial everything — a PO of 100 units arriving in three deliveries and billed across two invoices means matching must work against cumulative received and previously invoiced quantities per line, not document totals.
- Price changes between PO and invoice — freight surcharges and negotiated discounts are real. Legitimate changes go through PO amendment before the invoice posts; the match engine should never have a soften-the-rule mode.
- Returns after receipt — damaged goods returned post-GR reduce the matchable quantity. Vendor return documents must subtract from cumulative received, or the next invoice over-matches.
- Service POs with no physical receipt — services skip the GR, so they need a service acceptance confirmation as the second document, or an explicit 2-way match (PO vs invoice) policy per category with tighter approval instead.
Implementation Checklist
Before calling a procurement module production-ready, I verify these five in order:
- Line-level foreign keys all the way down the chain — PR to PO to GR to invoice — with cumulative quantities queryable per PO line in one indexed query.
- Approval matrix integration on both PR (need) and PO amendments (commitment), with snapshot-at-submission semantics.
- GR screen tested with actual warehouse staff on actual deliveries — including a partial and a damaged case — before go-live, not after.
- Match tolerances configured per category and signed off by finance, with every auto-release logged with its inputs for the auditors.
- Exception queue with side-by-side document view, ageing indicator, and weekly review ritual — an exception queue nobody empties is just a slower way of paying everything.
The Takeaway
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.

